mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +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:
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user