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
+6 -1
View File
@@ -585,6 +585,7 @@ const __impeccableSnapshot = {
for (let id = 1; id < elements.length; id++) {
const el = elements[id];
const rec = { t: el.tagName };
const tag = rec.t;
const nsUri = el.namespaceURI || '';
const ns = __SNAP_NS[nsUri];
if (ns === undefined) { rec.n = 3; rec.nu = nsUri; } else if (ns !== 0) { rec.n = ns; }
@@ -615,6 +616,11 @@ const __impeccableSnapshot = {
if (content == null || content === '' || content === 'none') continue;
rec[key] = __SNAP_PSEUDO_PROPS.map(p => intern(ps[p]));
}
if ((tag === 'INPUT' || tag === 'TEXTAREA') && el.getAttribute('placeholder')) {
let ps;
try { ps = getComputedStyle(el, '::placeholder'); } catch { ps = null; }
if (ps) rec.ph = intern(ps.color);
}
if (typeof el.getBoundingClientRect === 'function') rec.r = __snapRect4(el.getBoundingClientRect());
rec.m = [
__snapNum(el.clientWidth), __snapNum(el.clientHeight), __snapNum(el.clientLeft),
@@ -632,7 +638,6 @@ const __impeccableSnapshot = {
if (typeof el.className !== 'string') rec.k = true;
const st = states.get(id);
if (st) rec.st = st;
const tag = rec.t;
if (tag === 'IMG' || tag === 'VIDEO' || tag === 'CANVAS' || tag === 'PICTURE') {
rec.md = {
nw: el.naturalWidth || 0, nh: el.naturalHeight || 0,
+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"));
+33 -2
View File
@@ -12,8 +12,9 @@
//! element tree in document order (child nodes with their text, so
//! `textContent` and the direct text nodes come out byte-equal), attributes,
//! the computed-style properties the rules read (`STYLE_PROPS`, interned
//! values), `::before` / `::after` styles where `content` is set, bounding
//! rects, the client/scroll/offset metrics, `checkVisibility`, direct-text
//! values), `::before` / `::after` styles where `content` is set,
//! `::placeholder` `color` on text controls, bounding rects, the
//! client/scroll/offset metrics, `checkVisibility`, direct-text
//! rects, viewport and scroll, hostname, quirks mode, `body.innerText`, the
//! `@keyframes` rules, the document HTML for the regex pass, and the media
//! intrinsics the visual-contrast path needs.
@@ -253,6 +254,10 @@ pub struct SnapNode {
pub before: Option<Vec<u32>>,
#[serde(rename = "f", default)]
pub after: Option<Vec<u32>>,
/// Interned `getComputedStyle(el, '::placeholder').color` when the
/// element has a non-empty `placeholder` attribute.
#[serde(rename = "ph", default)]
pub placeholder_color: Option<u32>,
/// `getBoundingClientRect` as `[x, y, width, height]`; `None` when the
/// element has no such method.
#[serde(rename = "r", default)]
@@ -824,6 +829,11 @@ impl Dom for SnapshotDom {
}
fn pseudo_style(&self, el: ElId, pseudo: &str, prop: &str) -> Option<String> {
let n = self.snap.node(el);
if pseudo == "::placeholder" && prop == "color" {
return n
.placeholder_color
.and_then(|idx| self.snap.strings.get(idx as usize).cloned());
}
let vals = match pseudo {
"::before" | ":before" => n.before.as_ref(),
"::after" | ":after" => n.after.as_ref(),
@@ -999,6 +1009,27 @@ mod tests {
assert!(d.offset_width(6).is_nan());
}
#[test]
fn placeholder_color_is_readable_as_pseudo_style() {
let json = r#"{
"v": 1, "hostname": "example.test", "innerWidth": 1280, "innerHeight": 800,
"styleProps": ["display", "color"], "pseudoProps": ["content"],
"strings": ["block", "rgb(0, 0, 0)", "rgb(187, 187, 187)"],
"documentElement": 1, "body": 2,
"els": [
{"t":"HTML","c":[2],"s":[0,1],"r":[0,0,1280,800]},
{"t":"BODY","p":1,"c":[3],"s":[0,1]},
{"t":"INPUT","p":2,"c":[],"a":[["placeholder","Jane"]],"s":[0,1],"ph":2}
]
}"#;
let d = snap(json);
assert_eq!(
d.pseudo_style(3, "::placeholder", "color").as_deref(),
Some("rgb(187, 187, 187)")
);
assert_eq!(d.pseudo_style(2, "::placeholder", "color"), None);
}
#[test]
fn selectors_over_snapshot() {
let d = snap(SMALL);
+40 -4
View File
@@ -20,9 +20,9 @@ use impeccable_core::checks::measures::{
use impeccable_core::checks::rules::{
check_borders, check_colors, check_glow, check_hero_eyebrow, check_hover_contrast,
check_icon_tile, check_italic_serif, check_kicker_above_heading, check_motion,
is_emoji_only_text, is_heading_tag, resolve_hero_heading_size_px, BorderOpts, ColorOpts,
GlowOpts, HeroEyebrowOpts, HoverContrastOpts, IconTileOpts, ItalicSerifOpts, KickerCandidate,
MotionOpts, RuleHit, Sides,
check_placeholder_colors, is_emoji_only_text, is_heading_tag, resolve_hero_heading_size_px,
BorderOpts, ColorOpts, GlowOpts, HeroEyebrowOpts, HoverContrastOpts, IconTileOpts,
ItalicSerifOpts, KickerCandidate, MotionOpts, RuleHit, Sides,
};
use impeccable_core::checks::text_rules::{
check_numbered_section_labels, is_kicker_candidate, is_numbered_section_label_candidate,
@@ -539,7 +539,7 @@ pub fn check_element_colors(
sv(style, "backgroundClip")
}
};
check_colors(&ColorOpts {
let color_opts = ColorOpts {
tag: tag.to_string(),
text_color,
bg_color: own_bg,
@@ -557,7 +557,43 @@ pub fn check_element_colors(
bg_image: Some(sv(style, "backgroundImage").to_string()),
class_list: Some(el.class_name().to_string()),
detector_is_browser: false,
};
let mut findings = check_colors(&color_opts);
if tag == "input" || tag == "textarea" {
let placeholder = el.get_attribute("placeholder").unwrap_or("").trim();
if !placeholder.is_empty() {
let skip = if tag == "input" {
let t = js::to_lower_case(el.get_attribute("type").unwrap_or("text"));
matches!(
t.as_str(),
"hidden" | "checkbox" | "radio" | "file" | "submit" | "button" | "image"
| "reset" | "range" | "color"
) || el
.get_attribute("value")
.is_some_and(|v| !js::trim(v).is_empty())
} else {
!js::trim(&direct_text).is_empty()
};
if !skip {
if let Some(ph_style) = el.doc.get_placeholder_style(el.id()) {
let ph_color = custom_props
.and_then(|m| {
measures::parse_color_resolved(sv_opt(ph_style, "color"), Some(m))
})
.or_else(|| parse_rgb(sv_opt(ph_style, "color")))
.or_else(|| parse_any_color(sv_opt(ph_style, "color")));
if let Some(ph_color) = ph_color {
findings.extend(check_placeholder_colors(
&color_opts,
placeholder,
ph_color,
));
}
}
}
}
}
findings
}
/// JS: checks.mjs#checkElementHoverContrast(el, style, tag, window)
+62 -2
View File
@@ -139,6 +139,31 @@ static PSEUDO_RULE_RE: Lazy<Regex> = Lazy::new(|| {
))
.expect("PSEUDO_RULE_RE")
});
static PLACEHOLDER_RULE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^(.*)(?:::placeholder|::?-webkit-input-placeholder|::?-moz-placeholder)$")
.expect("PLACEHOLDER_RULE_RE")
});
fn placeholder_host_selector(selector: &str) -> Option<String> {
let pm = PLACEHOLDER_RULE_RE.captures(selector)?;
let captured = pm.get(1).map(|m| m.as_str()).unwrap_or("");
let trimmed_end = captured.trim_end_matches(|c: char| js::is_js_whitespace(c));
if trimmed_end.is_empty() {
return Some("*".to_string());
}
// Only fill a trailing empty compound. `star_empty_compounds` would
// rewrite `.label + ::placeholder` to `.label *+*`.
let last = trimmed_end.chars().last().unwrap();
if captured.len() != trimmed_end.len() || last == '>' || last == '+' || last == '~' {
if last == '>' || last == '+' || last == '~' {
Some(format!("{}*", trimmed_end))
} else {
Some(format!("{} *", trimmed_end))
}
} else {
Some(trimmed_end.to_string())
}
}
static COLOR_TOKEN_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}(?-u:\b)")
.expect("COLOR_TOKEN_RE")
@@ -252,6 +277,7 @@ pub fn build_static_style_map(
) {
let mut specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
let mut hover_specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
let mut placeholder_specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
let root_custom_props = collect_css_custom_props(css_text);
let rules = profile::step(
profile,
@@ -264,7 +290,12 @@ pub fn build_static_style_map(
Meta::new("selector-match", "css-selectors", file_path),
|| {
for rule in &rules {
if !rule.is_hover {
let placeholder_host = if rule.is_hover {
None
} else {
placeholder_host_selector(&rule.selector)
};
if !rule.is_hover && placeholder_host.is_none() {
if let Some(pm) = PSEUDO_RULE_RE.captures(&rule.selector) {
let base = pm.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
mark_pseudo_rule(doc, rule, &base, &root_custom_props);
@@ -273,6 +304,8 @@ pub fn build_static_style_map(
}
let match_selector: Option<&str> = if rule.is_hover {
rule.match_selector.as_deref()
} else if let Some(ref host) = placeholder_host {
Some(host.as_str())
} else {
Some(rule.selector.as_str())
};
@@ -296,11 +329,18 @@ pub fn build_static_style_map(
};
let store = if rule.is_hover {
&mut hover_specified
} else if placeholder_host.is_some() {
&mut placeholder_specified
} else {
&mut specified
};
for node in matched {
for decl in &rule.declarations {
if placeholder_host.is_some()
&& js::to_lower_case(&decl.prop) != "color"
{
continue;
}
let meta = DeclMeta {
important: decl.important,
specificity: rule.specificity,
@@ -341,7 +381,7 @@ pub fn build_static_style_map(
profile,
Meta::new("cascade", "compute-styles", file_path),
|| {
compute_styles(doc, &specified, &hover_specified);
compute_styles(doc, &specified, &hover_specified, &placeholder_specified);
},
);
}
@@ -353,6 +393,7 @@ fn compute_styles(
doc: &mut StaticDocument,
specified: &SpecifiedStore<NodeId>,
hover_specified: &SpecifiedStore<NodeId>,
placeholder_specified: &SpecifiedStore<NodeId>,
) {
let mut computed: HashMap<NodeId, Rc<StyleValues>> = HashMap::new();
let mut customs: HashMap<NodeId, Rc<CustomProps>> = HashMap::new();
@@ -368,6 +409,7 @@ fn compute_styles(
.map(|e| (e.id(), None))
.collect();
let mut hover_out: Vec<(NodeId, StyleValues)> = Vec::new();
let mut placeholder_out: Vec<(NodeId, StyleValues)> = Vec::new();
while let Some((node, parent)) = stack.pop() {
let parent_style: Option<Rc<StyleValues>> = parent.and_then(|p| computed.get(&p).cloned());
@@ -439,6 +481,21 @@ fn compute_styles(
}
}
if let Some(ph_map) = placeholder_specified.get(&node) {
if let Some(color_decl) = ph_map.get("color") {
let next = normalize_static_css_value(
"color",
&color_decl.value,
&custom_props,
Some(&values),
Some(&values),
);
let mut ph_style = StyleValues::default();
ph_style.insert("color".to_string(), next);
placeholder_out.push((node, ph_style));
}
}
let style_rc = Rc::new(values);
computed.insert(node, style_rc);
customs.insert(node, Rc::new(custom_props));
@@ -460,6 +517,9 @@ fn compute_styles(
for (node, style) in hover_out {
doc.set_hover_style(node, style);
}
for (node, style) in placeholder_out {
doc.set_placeholder_style(node, style);
}
}
/// `STATIC_DEFAULT_STYLE[prop]` lookup re-exported for the adapters.
+8
View File
@@ -55,6 +55,7 @@ pub struct StaticDocument {
pub html: Html,
styles: HashMap<NodeId, StyleValues>,
hover_styles: HashMap<NodeId, StyleValues>,
placeholder_styles: HashMap<NodeId, StyleValues>,
accent_dash: HashSet<NodeId>,
pseudo_surface: HashMap<NodeId, Rgba>,
selector_cache: RefCell<HashMap<String, Result<Selector, SelectorError>>>,
@@ -174,6 +175,7 @@ impl StaticDocument {
html,
styles: HashMap::new(),
hover_styles: HashMap::new(),
placeholder_styles: HashMap::new(),
accent_dash: HashSet::new(),
pseudo_surface: HashMap::new(),
selector_cache: RefCell::new(HashMap::new()),
@@ -319,6 +321,12 @@ impl StaticDocument {
pub fn get_hover_style(&self, node: NodeId) -> Option<&StyleValues> {
self.hover_styles.get(&node)
}
pub fn set_placeholder_style(&mut self, node: NodeId, style: StyleValues) {
self.placeholder_styles.insert(node, style);
}
pub fn get_placeholder_style(&self, node: NodeId) -> Option<&StyleValues> {
self.placeholder_styles.get(&node)
}
pub fn set_accent_dash_pseudo(&mut self, node: NodeId) {
self.accent_dash.insert(node);
}
+122
View File
@@ -0,0 +1,122 @@
//! Integration tests for `::placeholder` contrast detection (#790).
use impeccable_html::{detect_html_source, DetectHtmlOptions};
use std::path::Path;
const ISSUER_REPRO: &str = r#"<!DOCTYPE html>
<html><head><style>
input::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; border: 1px solid #ccc; padding: 8px; box-sizing: border-box; }
</style></head>
<body><input placeholder="Search"></body></html>
"#;
fn scan(html: &str) -> Vec<impeccable_core::findings::Finding> {
detect_html_source(html, Path::new("/tmp/placeholder.html"), &DetectHtmlOptions::default())
}
fn repo_root() -> std::path::PathBuf {
std::env::var("IMPECCABLE_PUBLIC_REPO")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."))
}
#[test]
fn issuer_repro_flags_pale_placeholder() {
let findings = scan(ISSUER_REPRO);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast finding, got {findings:?}"
);
}
#[test]
fn bare_placeholder_selector_flags() {
let html = r#"<!DOCTYPE html>
<html><head><style>
::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; }
</style></head>
<body><input placeholder="Search"></body></html>
"#;
let findings = scan(html);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast for bare ::placeholder, got {findings:?}"
);
}
#[test]
fn descendant_placeholder_selector_flags() {
let html = r#"<!DOCTYPE html>
<html><head><style>
.form ::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; }
</style></head>
<body><div class="form"><input placeholder="Search"></div></body></html>
"#;
let findings = scan(html);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast for descendant ::placeholder, got {findings:?}"
);
}
#[test]
fn sibling_placeholder_selector_flags() {
let html = r#"<!DOCTYPE html>
<html><head><style>
.label + ::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; }
</style></head>
<body><label class="label">Name</label><input placeholder="Search"></body></html>
"#;
let findings = scan(html);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast for sibling ::placeholder, got {findings:?}"
);
}
#[test]
fn fixture_flag_and_pass_cases() {
let fixture = repo_root().join("tests/fixtures/antipatterns/placeholder-contrast.html");
assert!(
fixture.is_file(),
"missing fixture at {}",
fixture.display()
);
let html = std::fs::read_to_string(&fixture).unwrap();
let findings = detect_html_source(&html, &fixture, &DetectHtmlOptions::default());
let ids: Vec<&str> = findings.iter().map(|f| f.antipattern.as_str()).collect();
let snippets: Vec<&str> = findings.iter().map(|f| f.snippet.as_str()).collect();
for needle in [
"Pale Placeholder On White Field",
"Pale Placeholder On White Textarea",
"Translucent Placeholder On Light Field",
"Pale Placeholder On Frosted Panel",
] {
assert!(
snippets.iter().any(|s| s.contains(needle)),
"expected flag for placeholder {needle:?}, findings={findings:?}"
);
}
for needle in [
"Ink Placeholder On White Field",
"Light Placeholder On Dark Field",
"Filled Field Hides Placeholder",
"Unstyled Placeholder Uses UA Color",
] {
assert!(
!snippets.iter().any(|s| s.contains(needle)),
"pass case {needle:?} should not flag, findings={findings:?}"
);
}
assert!(
ids.iter().filter(|id| **id == "low-contrast").count() >= 4,
"expected at least four low-contrast hits, got {findings:?}"
);
}
File diff suppressed because one or more lines are too long
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Placeholder Contrast — Should Flag vs Should Pass</title>
<style>
body { font-family: system-ui, sans-serif; background: #fafafa; padding: 24px; margin: 0; color: #1a1a1a; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 1200px; margin: 0 auto; }
.col h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
.col h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin: 24px 0 8px; color: #64748b; }
.field {
width: 280px;
height: 40px;
padding: 8px 12px;
font-size: 16px;
border: 1px solid #cbd5e1;
border-radius: 6px;
box-sizing: border-box;
display: block;
margin-bottom: 8px;
}
.field-white { background: #ffffff; }
.field-light { background: #f5f5f5; }
.field-dark { background: #1a1a1a; border-color: #333; }
.flag-pale-white::placeholder { color: #bbbbbb; }
.flag-pale-textarea::placeholder { color: #bbbbbb; }
.flag-translucent-light::placeholder { color: rgba(255, 255, 255, 0.4); }
.dark-wrap { background: #0f0f11; padding: 20px; width: 320px; }
.frosted-panel {
background: rgba(255, 255, 255, 0.15);
padding: 12px;
}
.frosted-panel .field {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.frosted-panel .field::placeholder { color: #bbbbbb; }
.pass-ink-white::placeholder { color: #1a1a1a; }
.pass-light-dark::placeholder { color: #e8e8e8; }
.pass-filled-pale::placeholder { color: #bbbbbb; }
.pass-unstyled { /* no ::placeholder rule — UA color only */ }
</style>
</head>
<body>
<div class="grid">
<div class="col" data-col="flag">
<h2>Should flag</h2>
<h3>Pale placeholder on white input</h3>
<input class="field field-white flag-pale-white" type="text" placeholder="Pale Placeholder On White Field">
<h3>Pale placeholder on white textarea</h3>
<textarea class="field field-white flag-pale-textarea" rows="2" placeholder="Pale Placeholder On White Textarea"></textarea>
<h3>Translucent placeholder on light field</h3>
<input class="field field-light flag-translucent-light" type="text" placeholder="Translucent Placeholder On Light Field">
<h3>Pale placeholder on frosted panel</h3>
<div class="dark-wrap">
<div class="frosted-panel">
<input class="field" type="text" placeholder="Pale Placeholder On Frosted Panel">
</div>
</div>
</div>
<div class="col" data-col="pass">
<h2>Should pass</h2>
<h3>Ink placeholder on white field</h3>
<input class="field field-white pass-ink-white" type="text" placeholder="Ink Placeholder On White Field">
<h3>Light placeholder on dark field</h3>
<input class="field field-dark pass-light-dark" type="text" placeholder="Light Placeholder On Dark Field">
<h3>Input with no placeholder attribute</h3>
<input class="field field-white" type="text" value="">
<h3>Filled field hides placeholder</h3>
<input class="field field-white pass-filled-pale" type="text" value="Already filled" placeholder="Filled Field Hides Placeholder">
<h3>Unstyled placeholder uses UA color</h3>
<input class="field field-white pass-unstyled" type="text" placeholder="Unstyled Placeholder Uses UA Color">
<h3>Empty placeholder attribute</h3>
<input class="field field-white flag-pale-white" type="text" placeholder="">
</div>
</div>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "415 anti-patterns found.\n17 advisory notes (not counted).\n",
"stderr": "419 anti-patterns found.\n17 advisory notes (not counted).\n",
"exit": 2,
"signal": null,
"files": {}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
{
"stdout": "[\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Pale Placeholder On White Field\\\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Pale Placeholder On White Textarea\\\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Translucent Placeholder On Light Field\\\" 1.0:1 (need 4.5:1) — text #f9f9f9 on #f5f5f5\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Pale Placeholder On Frosted Panel\\\" 3.5:1 (need 4.5:1) — text #bbbbbb on #5c5c5d\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\n [low-contrast] placeholder \"Pale Placeholder On White Field\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] placeholder \"Pale Placeholder On White Textarea\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] placeholder \"Translucent Placeholder On Light Field\" 1.0:1 (need 4.5:1) — text #f9f9f9 on #f5f5f5\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] placeholder \"Pale Placeholder On Frosted Panel\" 3.5:1 (need 4.5:1) — text #bbbbbb on #5c5c5d\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n\n4 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long