Files
pbakaus_impeccable/crates/core/src/browser/driver.rs
T
Paul Bakaus 7454f5414d Merge main into stripe-child detection
Preserve stripe-child findings while adopting upstream rendered-rule changes. Rebuild the detector bundle and update only upstream descriptions in stripe and aggregate oracle goldens. Full Rust, rebuilt-engine Bun/Node, distribution and real Chrome fixture checks pass.

AI-assisted by Codex under pbakaus's instructions.
2026-09-21 11:15:06 -07:00

2053 lines
79 KiB
Rust

//! index.mjs driver: `scopedIgnoreActive`, `collectBrowserFindings`, the
//! design-system checks, `serializeFindings`, `generateSelector`. See
//! browser/mod.rs. (Pipeline-proof skeleton; the full port replaces the body
//! of `collect_browser_findings`.)
#![allow(unused_imports)]
use super::dom::{tag_lower, Dom, ElId, Rect};
use super::element_checks::check_element_borders_dom;
use super::{BrowserConfig, BrowserFinding, DisabledValue, FindingGroup};
use crate::js_ext_a::JsMap;
use serde::Serialize;
/// The collect result type is shared.
pub use impeccable_foundation::browser::CollectResult;
/// JS: checks.mjs#scopedIgnoreActive(el, ruleId)
pub fn scoped_ignore_active(dom: &dyn Dom, el: ElId, rule_id: &str) -> bool {
let rule = crate::js::to_lower_case(rule_id);
// Handle 0 is JS null (a missing document.body): the walk never runs.
let mut cur = if el == 0 { None } else { Some(el) };
while let Some(c) = cur {
if let Some(attr) = dom.attr(c, "data-impeccable-ignore") {
let lowered = crate::js::to_lower_case(crate::js::trim(&attr));
let rules: Vec<&str> = SPLIT_RE
.split(&lowered)
.filter(|s| !s.is_empty())
.collect();
if rules.is_empty() || rules.contains(&"*") || rules.contains(&rule.as_str()) {
return true;
}
}
cur = dom.parent(c);
}
false
}
static SPLIT_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new(&format!("[{},]+", crate::js::WS_CHARS)).expect("SPLIT_RE")
});
/// Group-map insertion (`addBrowserFindings`): scoped-ignore filter, then
/// append to the element's list or start one.
pub fn add_browser_findings(
dom: &dyn Dom,
groups: &mut Vec<FindingGroup>,
el: ElId,
findings: Vec<BrowserFinding>,
) {
if findings.is_empty() {
return;
}
let kept: Vec<BrowserFinding> = findings
.into_iter()
.filter(|f| !scoped_ignore_active(dom, el, &f.type_))
.collect();
if kept.is_empty() {
return;
}
if let Some(g) = groups.iter_mut().find(|g| g.el == el) {
g.findings.extend(kept);
} else {
groups.push(FindingGroup { el, findings: kept });
}
}
// ─── Design system (index.mjs) ──────────────────────────────────────────────
/// The `seen` sets `collectBrowserFindings` threads through the element loop
/// (JS `designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() }`).
#[derive(Debug, Default)]
pub struct DesignSeen {
pub fonts: Vec<String>,
pub colors: Vec<String>,
pub radii: Vec<String>,
}
/// JS: index.mjs#browserDesignSystemConfig() — parsed once per collect;
/// `None` when `!raw?.present`.
#[derive(Debug, Clone, Default)]
pub struct DesignSystemConfig {
/// Selectors the repository's design document names as its own, e.g.
/// `.eyebrow` written into DESIGN.md. A rule that would charge one of
/// these is reviewing the design system rather than the change (REN-406).
pub declared_selectors: Vec<String>,
pub has_fonts: bool,
pub allowed_fonts: Vec<String>,
pub has_colors: bool,
pub allowed_colors: Vec<crate::color::Rgba>,
pub has_radii: bool,
pub allowed_radii: Vec<f64>,
pub has_pill_radius: bool,
}
const DESIGN_COLOR_TOLERANCE: f64 = 6.0;
const DESIGN_RADIUS_TOLERANCE_PX: f64 = 0.5;
const DESIGN_SKIP_TAGS: &[&str] = &[
"head", "title", "meta", "link", "style", "script", "noscript", "template", "source",
];
static WS_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new(&format!("{}+", crate::js::WS)).expect("WS_RE")
});
static VAR_RE: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| regex::Regex::new(&format!("{}\\(", crate::js::ci("var"))).expect("VAR_RE"));
static SLASH_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new(&format!("{ws}*/{ws}*", ws = crate::js::WS)).expect("SLASH_RE")
});
/// JS `String(value || '')` for a JSON value.
fn js_string_or_empty(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => {
let f = n.as_f64().unwrap_or(f64::NAN);
if f == 0.0 || f.is_nan() {
String::new()
} else {
crate::js::number_to_string(f)
}
}
serde_json::Value::Bool(true) => "true".to_string(),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => v.to_string(),
_ => String::new(),
}
}
/// JS `Number(value)` for a JSON value.
fn js_number(v: &serde_json::Value) -> f64 {
match v {
serde_json::Value::Number(n) => n.as_f64().unwrap_or(f64::NAN),
serde_json::Value::String(s) => crate::js::string_to_number(s),
serde_json::Value::Bool(b) => {
if *b {
1.0
} else {
0.0
}
}
serde_json::Value::Null => 0.0,
serde_json::Value::Array(a) => {
if a.is_empty() {
0.0
} else if a.len() == 1 {
js_number(&a[0])
} else {
f64::NAN
}
}
serde_json::Value::Object(_) => f64::NAN,
}
}
/// JS truthiness of a JSON value.
fn truthy(v: &serde_json::Value) -> bool {
match v {
serde_json::Value::Null => false,
serde_json::Value::Bool(b) => *b,
serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(false),
serde_json::Value::String(s) => !s.is_empty(),
_ => true,
}
}
/// JS: index.mjs#normalizeBrowserFontName(value)
pub fn normalize_browser_font_name(value: &str) -> String {
let t = crate::js::trim(value);
// `.replace(/^["']|["']$/g, '')`: one leading and one trailing quote.
let t = t.strip_prefix(['"', '\'']).unwrap_or(t);
let t = t.strip_suffix(['"', '\'']).unwrap_or(t);
let t = t.replace('+', " ");
let t = WS_RE.replace_all(&t, " ");
crate::js::to_lower_case(&t)
}
/// JS: index.mjs#browserPrimaryFont(stack)
pub fn browser_primary_font(stack: &str) -> String {
if stack.is_empty() || VAR_RE.is_match(stack) {
return String::new();
}
stack
.split(',')
.map(normalize_browser_font_name)
.find(|font| !font.is_empty() && !crate::constants::GENERIC_FONTS.contains(&font.as_str()))
.unwrap_or_default()
}
/// JS: index.mjs#browserDesignSystemConfig()
pub fn browser_design_system_config(config: &BrowserConfig) -> Option<DesignSystemConfig> {
let raw = config.design_system.as_ref()?;
let obj = raw.as_object()?;
// JS `!raw?.present`.
if !obj.get("present").map_or(false, truthy) {
return None;
}
let arr = |k: &str| -> Vec<serde_json::Value> {
obj.get(k).and_then(|v| v.as_array()).cloned().unwrap_or_default()
};
let mut allowed_fonts: Vec<String> = Vec::new();
for v in arr("allowedFonts") {
let f = normalize_browser_font_name(&js_string_or_empty(&v));
if !f.is_empty() && !allowed_fonts.contains(&f) {
allowed_fonts.push(f);
}
}
let allowed_colors: Vec<crate::color::Rgba> = arr("allowedColors")
.iter()
.filter_map(|c| {
let o = c.as_object()?;
let r = o.get("r").and_then(|v| v.as_f64())?;
let g = o.get("g").and_then(|v| v.as_f64())?;
let b = o.get("b").and_then(|v| v.as_f64())?;
if r.is_finite() && g.is_finite() && b.is_finite() {
Some(crate::color::Rgba { r, g, b, a: None })
} else {
None
}
})
.collect();
let allowed_radii: Vec<f64> = arr("allowedRadii")
.iter()
.map(js_number)
.filter(|px| px.is_finite())
.collect();
let declared_selectors: Vec<String> = arr("declaredSelectors")
.iter()
.map(js_string_or_empty)
.map(|s| crate::js::trim(&s).to_string())
.filter(|s| !s.is_empty())
.collect();
let is_true = |k: &str| matches!(obj.get(k), Some(serde_json::Value::Bool(true)));
Some(DesignSystemConfig {
declared_selectors,
has_fonts: is_true("hasFonts") && !allowed_fonts.is_empty(),
allowed_fonts,
has_colors: is_true("hasColors") && !allowed_colors.is_empty(),
allowed_colors,
has_radii: is_true("hasRadii") && !allowed_radii.is_empty(),
allowed_radii,
has_pill_radius: is_true("hasPillRadius"),
})
}
/// JS: index.mjs#browserColorsClose(a, b)
pub fn browser_colors_close(a: &crate::color::Rgba, b: &crate::color::Rgba) -> bool {
crate::js::math_max3((a.r - b.r).abs(), (a.g - b.g).abs(), (a.b - b.b).abs())
<= DESIGN_COLOR_TOLERANCE
}
/// JS: index.mjs#isBrowserDesignColorAllowed(raw, designSystem)
pub fn is_browser_design_color_allowed(raw: &str, ds: Option<&DesignSystemConfig>) -> bool {
let Some(ds) = ds else { return true };
if !ds.has_colors {
return true;
}
let text = crate::js::to_lower_case(crate::js::trim(raw));
if text.is_empty()
|| text == "transparent"
|| text == "currentcolor"
|| text == "inherit"
|| text == "initial"
{
return true;
}
if text.contains("var(") {
return true;
}
let Some(parsed) = crate::color::parse_any_color(Some(&text)) else {
return true;
};
if parsed.alpha_or_one() <= 0.05 {
return true;
}
ds.allowed_colors
.iter()
.any(|c| browser_colors_close(&parsed, c))
}
/// JS: index.mjs#isBrowserTransparentCss(value)
pub fn is_browser_transparent_css(value: &str) -> bool {
let text = crate::js::to_lower_case(crate::js::trim(value));
if text.is_empty() || text == "transparent" {
return true;
}
match crate::color::parse_any_color(Some(&text)) {
Some(c) => c.alpha_or_one() <= 0.05,
None => false,
}
}
/// JS: index.mjs#isBrowserDesignRadiusAllowed(raw, designSystem)
pub fn is_browser_design_radius_allowed(raw: &str, ds: Option<&DesignSystemConfig>) -> bool {
let Some(ds) = ds else { return true };
if !ds.has_radii {
return true;
}
let text = crate::js::to_lower_case(crate::js::trim(raw));
if text.is_empty() || text == "0" || text == "none" || text == "initial" || text == "inherit" {
return true;
}
if text.contains("var(") || text.contains('%') {
return true;
}
let Some(px) = crate::checks::measures::resolve_length_px(Some(&text), 16.0) else {
return true;
};
if !px.is_finite() || px <= DESIGN_RADIUS_TOLERANCE_PX {
return true;
}
if ds.has_pill_radius && px >= 99.0 {
return true;
}
ds.allowed_radii
.iter()
.any(|allowed| (allowed - px).abs() <= DESIGN_RADIUS_TOLERANCE_PX)
}
/// JS: index.mjs#browserRadiusTokens(value)
pub fn browser_radius_tokens(value: &str) -> Vec<String> {
let v = SLASH_RE.replace_all(value, " ");
WS_RE
.split(&v)
.map(|t| crate::js::trim(t).to_string())
.filter(|t| !t.is_empty())
.collect()
}
/// JS: index.mjs#browserHasDirectText(el)
pub fn browser_has_direct_text(dom: &dyn Dom, el: ElId) -> bool {
dom.direct_text_nodes(el)
.iter()
.any(|t| !crate::js::trim(t).is_empty())
}
/// JS: index.mjs#browserSampleText(el)
pub fn browser_sample_text(dom: &dyn Dom, el: ElId) -> String {
let raw = dom.text_content(el);
let text = WS_RE.replace_all(&raw, " ");
let text = crate::js::trim(&text);
if text.is_empty() {
String::new()
} else {
format!(" \"{}\"", crate::js_ext_b::slice_utf16_prefix(text, 40))
}
}
/// JS: index.mjs#shouldSkipDesignElement(el)
pub fn should_skip_design_element(dom: &dyn Dom, el: ElId) -> bool {
let tag = tag_lower(dom, el);
DESIGN_SKIP_TAGS.contains(&tag.as_str()) || is_element_hidden(dom, el)
}
/// JS: index.mjs#checkElementDesignSystemDOM(el, designSystem, seen)
pub fn check_element_design_system_dom(
dom: &dyn Dom,
el: ElId,
ds: Option<&DesignSystemConfig>,
seen: &mut DesignSeen,
) -> Vec<BrowserFinding> {
let Some(ds) = ds else { return Vec::new() };
if should_skip_design_element(dom, el) {
return Vec::new();
}
let mut findings = Vec::new();
let tag = {
let t = tag_lower(dom, el);
if t.is_empty() {
"unknown".to_string()
} else {
t
}
};
let ignore = |type_: &str, detail: String, value: String| BrowserFinding {
type_: type_.to_string(),
detail,
severity: None,
ignore_value: Some(value),
};
if ds.has_fonts && browser_has_direct_text(dom, el) {
let font = browser_primary_font(&dom.style(el, "fontFamily"));
if !font.is_empty() && !ds.allowed_fonts.contains(&font) && !seen.fonts.contains(&font) {
seen.fonts.push(font.clone());
findings.push(ignore(
"design-system-font",
format!(
"{}{} uses {}; not declared in DESIGN.md typography",
tag,
browser_sample_text(dom, el),
font
),
font,
));
}
}
if ds.has_colors {
let mut color_checks: Vec<(String, String)> = Vec::new();
if browser_has_direct_text(dom, el) {
color_checks.push(("text color".to_string(), dom.style(el, "color")));
}
let bg = dom.style(el, "backgroundColor");
if !is_browser_transparent_css(&bg) {
color_checks.push(("background".to_string(), bg));
}
for side in ["Top", "Right", "Bottom", "Left"] {
if super::dom::style_px(dom, el, &format!("border{side}Width")) > 0.0 {
color_checks.push((
format!("border-{}", crate::js::to_lower_case(side)),
dom.style(el, &format!("border{side}Color")),
));
}
}
if super::dom::style_px(dom, el, "outlineWidth") > 0.0 {
color_checks.push(("outline".to_string(), dom.style(el, "outlineColor")));
}
for (kind, raw) in color_checks {
let label = WS_RE.replace_all(crate::js::trim(&raw), " ").into_owned();
if is_browser_design_color_allowed(&label, Some(ds)) {
continue;
}
let key = format!("{kind}:{label}");
if seen.colors.contains(&key) {
continue;
}
seen.colors.push(key);
findings.push(ignore(
"design-system-color",
format!(
"{} {} on {}{} is outside DESIGN.md colors",
kind,
label,
tag,
browser_sample_text(dom, el)
),
label,
));
}
}
if ds.has_radii {
for token in browser_radius_tokens(&dom.style(el, "borderRadius")) {
if is_browser_design_radius_allowed(&token, Some(ds)) {
continue;
}
if seen.radii.contains(&token) {
continue;
}
seen.radii.push(token.clone());
findings.push(ignore(
"design-system-radius",
format!(
"border-radius {} on {}{} is outside the DESIGN.md rounded scale",
token,
tag,
browser_sample_text(dom, el)
),
token,
));
}
}
findings
}
/// JS `decodeURIComponent(s)`: `None` where it throws (malformed escape,
/// invalid UTF-8).
pub fn decode_uri_component(s: &str) -> Option<String> {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len() {
return None;
}
let h = (bytes[i + 1] as char).to_digit(16)?;
let l = (bytes[i + 2] as char).to_digit(16)?;
out.push((h * 16 + l) as u8);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).ok()
}
/// JS: index.mjs#decodeBrowserGoogleFamily(value)
pub fn decode_browser_google_family(value: &str) -> String {
let family = value.split(':').next().unwrap_or("").replace('+', " ");
decode_uri_component(&family).unwrap_or(family)
}
static GOOGLE_FAMILY_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new(r"[?&]family=([^&]+)").expect("GOOGLE_FAMILY_RE")
});
/// JS: index.mjs#checkBrowserDesignSystemSources(designSystem, seen)
pub fn check_browser_design_system_sources(
dom: &dyn Dom,
ds: Option<&DesignSystemConfig>,
seen: &mut DesignSeen,
) -> Vec<BrowserFinding> {
let Some(ds) = ds else { return Vec::new() };
if !ds.has_fonts {
return Vec::new();
}
let mut findings = Vec::new();
for link in dom
.query_all(None, "link[href*=\"fonts.googleapis.com/css\"]")
.unwrap_or_default()
{
let href = dom.attr(link, "href").unwrap_or_default();
for m in GOOGLE_FAMILY_RE.captures_iter(&href) {
let display = decode_browser_google_family(&m[1]);
let font = normalize_browser_font_name(&display);
if font.is_empty() || ds.allowed_fonts.contains(&font) || seen.fonts.contains(&font) {
continue;
}
seen.fonts.push(font);
findings.push(BrowserFinding {
type_: "design-system-font".to_string(),
detail: format!(
"Google Fonts: {} is not declared in DESIGN.md typography",
display
),
severity: None,
ignore_value: Some(display),
});
}
}
findings
}
// ─── Regex-on-HTML pass ─────────────────────────────────────────────────────
static PSEUDO_SEGMENT_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new(r"::?[a-zA-Z-]+(\([^)]*\))?").expect("PSEUDO_SEGMENT_RE")
});
static ONLY_COMMAS_WS_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new(&format!("^[,{}]*$", crate::js::WS_CHARS)).expect("ONLY_COMMAS_WS_RE")
});
/// JS `s.replace(/,\s*(?=,|$)/g, '')`: drop a comma (and the whitespace
/// after it) that is followed by another comma or the end.
fn drop_dangling_commas(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < chars.len() {
if chars[i] == ',' {
let mut j = i + 1;
while j < chars.len() && crate::js::is_js_whitespace(chars[j]) {
j += 1;
}
if j >= chars.len() || chars[j] == ',' {
i = j;
continue;
}
}
out.push(chars[i]);
i += 1;
}
out
}
/// The live-DOM scope of a CSS-text finding's selector: pseudo segments
/// stripped, dangling commas removed. `None` when nothing queryable is left
/// (the finding stays page-level).
///
/// Superseded by [`pseudo_element_host_selector`] for the driver's own
/// filter (#709); kept because the static engine still spells the scope this
/// way.
pub fn html_pattern_query(selector: &str) -> Option<String> {
let stripped = PSEUDO_SEGMENT_RE.replace_all(selector, "");
let query = drop_dangling_commas(crate::js::trim(&stripped));
if query.is_empty() || ONLY_COMMAS_WS_RE.is_match(&query) {
return None;
}
Some(query)
}
fn is_selector_name_char(c: Option<char>) -> bool {
matches!(c, Some(c) if c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
/// JS: injected/index.mjs#pseudoElementHostSelector
///
/// Rewrites a selector so its pseudo-elements resolve to the live element
/// that originates them: `.card::before` to `.card`, and a hostless
/// `main > ::before` to `main > *`. `None` when the selector carries no
/// pseudo-element at all, which is the caller's signal that the full
/// selector is queryable as written.
///
/// JS-PARITY: the JS indexes UTF-16 code units; this walks chars, which
/// differs only for an astral character inside a selector literal.
pub fn pseudo_element_host_selector(selector: &str) -> Option<String> {
const LEGACY_NAMES: &[&str] = &["before", "after", "first-letter", "first-line"];
let raw: Vec<char> = selector.chars().collect();
let consume_function = |start: usize| -> usize {
let mut depth: i32 = 0;
let mut quote: Option<char> = None;
let mut i = start;
while i < raw.len() {
let ch = raw[i];
if ch == '\\' {
i += 2;
continue;
}
if let Some(q) = quote {
if ch == q {
quote = None;
}
i += 1;
continue;
}
if ch == '"' || ch == '\'' {
quote = Some(ch);
i += 1;
continue;
}
if ch == '(' {
depth += 1;
}
if ch == ')' {
depth -= 1;
if depth == 0 {
return i + 1;
}
}
i += 1;
}
raw.len()
};
let mut output = String::new();
let mut found = false;
let mut i = 0usize;
while i < raw.len() {
let ch = raw[i];
if ch == '\\' {
let end = raw.len().min(i + 2);
output.extend(&raw[i..end]);
i += 2;
continue;
}
if ch == '"' || ch == '\'' {
let quote = ch;
let start = i;
i += 1;
while i < raw.len() {
if raw[i] == '\\' {
i += 2;
continue;
}
let value = raw[i];
i += 1;
if value == quote {
break;
}
}
output.extend(&raw[start..raw.len().min(i)]);
continue;
}
if ch != ':' {
output.push(ch);
i += 1;
continue;
}
let mut end = i + 1;
let is_pseudo_element;
if raw.get(end) == Some(&':') {
end += 1;
let name_start = end;
while is_selector_name_char(raw.get(end).copied()) {
end += 1;
}
is_pseudo_element = end > name_start;
} else {
let name_start = end;
while is_selector_name_char(raw.get(end).copied()) {
end += 1;
}
let name: String = raw[name_start..end].iter().collect();
is_pseudo_element = LEGACY_NAMES.contains(&crate::js::to_lower_case(&name).as_str());
}
if !is_pseudo_element {
output.push(ch);
i += 1;
continue;
}
if raw.get(end) == Some(&'(') {
end = consume_function(end);
}
found = true;
let last = output.chars().last();
if last.is_none()
|| matches!(last, Some(c) if crate::js::is_js_whitespace(c)
|| c == '>' || c == '+' || c == '~' || c == ',')
{
output.push('*');
}
i = end;
}
if !found {
return None;
}
Some(drop_dangling_commas(crate::js::trim(&output)))
}
/// JS: injected/index.mjs#selectorNodesForLiveDom
///
/// `None` means "unresolvable": the DOM API refused the selector, or the
/// pseudo-element rewrite left nothing queryable. An empty vector from a
/// selector the DOM did accept is authoritative, so an inactive
/// `:hover` / `:focus` / `:not()` rule is never broadened to its host.
pub fn selector_nodes_for_live_dom(dom: &dyn Dom, selector: &str) -> Option<Vec<ElId>> {
let raw = crate::js::trim(selector);
if raw.is_empty() {
return None;
}
let Some(fallback) = pseudo_element_host_selector(raw) else {
return dom.query_all(None, raw).ok();
};
if fallback.is_empty() || ONLY_COMMAS_WS_RE.is_match(&fallback) {
return None;
}
dom.query_all(None, &fallback).ok()
}
/// The regex-on-HTML pass of collectBrowserFindings: `checkHtmlPatterns` on
/// the live document's HTML, selector-scoped filtering against the live DOM
/// (a selector matching nothing drops the finding; a match under a
/// data-impeccable-ignore ancestor is waived), and the mapping with the
/// pulsing-dot hero promotion. Returns `{ type, detail, severity? }`; the
/// caller applies `_ruleOk`.
pub fn scoped_html_pattern_findings(dom: &dyn Dom) -> Vec<BrowserFinding> {
let html = dom.document_html_for_patterns();
// Linked stylesheets are absent from the page's outerHTML, so the probe
// hands their readable, live-resolving rules to the style corpus (#709).
let mut corpora = crate::checks::html_patterns::build_html_pattern_corpora(&html);
let linked_css = dom.linked_stylesheet_text();
if !linked_css.is_empty() {
corpora.style_text.push('\n');
corpora.style_text.push_str(&linked_css);
}
let all = crate::checks::html_patterns::check_html_patterns(&html, Some(&corpora));
let mut out = Vec::new();
for f in all {
if let Some(selector) = f.selector.as_deref().filter(|s| !s.is_empty()) {
let Some(matches) = selector_nodes_for_live_dom(dom, selector) else {
continue;
};
if matches.is_empty() {
continue;
}
if !matches.iter().any(|el| !scoped_ignore_active(dom, *el, &f.id)) {
continue;
}
}
let mut item = BrowserFinding::new(f.id.clone(), f.snippet.clone());
if let Some(sev) = f.severity.as_ref().filter(|s| !s.is_empty()) {
item.severity = Some(sev.clone());
} else if f.id == "pulsing-dot" {
if let Some(selector) = f.selector.as_deref().filter(|s| !s.is_empty()) {
if let Ok(Some(dot)) = dom.query_one(None, selector) {
let rect = dom.rect(dot);
let page_top = rect.top + dom.scroll_y();
if page_top <= 900.0 {
item.severity = Some("error".to_string());
}
}
}
}
out.push(item);
}
out
}
/// JS: index.mjs#serializeFindings(allFindings)
pub fn serialize_findings(dom: &dyn Dom, groups: &[FindingGroup]) -> serde_json::Value {
use crate::registry::get_antipattern;
use serde_json::{json, Map, Value};
let body = dom.body();
let root = dom.document_element();
let mut out = Vec::with_capacity(groups.len());
for g in groups {
let el = g.el;
let is_page_level = Some(el) == body || Some(el) == root;
// JS `el.tagName?.toLowerCase() || 'unknown'`; a null body key has no
// tagName.
let tag_name = if el == 0 {
"unknown".to_string()
} else {
let t = tag_lower(dom, el);
if t.is_empty() {
"unknown".to_string()
} else {
t
}
};
let rect: Value = if el != 0 && !is_page_level {
let r = dom.rect(el);
json!({
"x": r.x, "y": r.y, "width": r.width, "height": r.height,
"top": r.top, "right": r.right, "bottom": r.bottom, "left": r.left,
})
} else {
Value::Null
};
let findings: Vec<Value> = g
.findings
.iter()
.map(|f| {
let ap = get_antipattern(&f.type_);
let severity = match (&f.severity, ap) {
(Some(s), _) if !s.is_empty() => s.clone(),
(_, Some(ap)) => ap.severity.unwrap_or("warning").to_string(),
_ => "warning".to_string(),
};
let mut m = Map::new();
m.insert("type".into(), Value::String(f.type_.clone()));
m.insert(
"category".into(),
Value::String(ap.map(|a| a.category).unwrap_or("quality").to_string()),
);
// Per-finding promotions override the registry default, so
// derive the advisory flag strictly from the effective
// severity (#709).
let advisory = severity == "advisory";
m.insert("severity".into(), Value::String(severity));
m.insert("advisory".into(), Value::Bool(advisory));
m.insert("detail".into(), Value::String(f.detail.clone()));
m.insert(
"ignoreValue".into(),
Value::String(f.ignore_value.clone().unwrap_or_default()),
);
m.insert(
"name".into(),
Value::String(ap.map(|a| a.name.to_string()).unwrap_or_else(|| f.type_.clone())),
);
m.insert(
"description".into(),
Value::String(ap.map(|a| a.description).unwrap_or("").to_string()),
);
Value::Object(m)
})
.collect();
let mut m = Map::new();
m.insert(
"selector".into(),
Value::String(if el == 0 { "body".into() } else { generate_selector(dom, el) }),
);
m.insert("tagName".into(), Value::String(tag_name));
m.insert("rect".into(), rect);
m.insert("isPageLevel".into(), Value::Bool(is_page_level));
m.insert("isHidden".into(), Value::Bool(if el == 0 { false } else { is_element_hidden(dom, el) }));
m.insert("findings".into(), Value::Array(findings));
out.push(Value::Object(m));
}
Value::Array(out)
}
// JS `/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i`, `/^_[\w-]{5,}$/`,
// `/^[a-z0-9]{6,}$/i` (JS `\w` is ASCII; the `i` flag folds ASCII only).
static HASHED_1: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new(&format!(
"^({}|{}|{}|{}|{})-[A-Za-z0-9_-]{{4,}}$",
crate::js::ci("css"),
crate::js::ci("sc"),
crate::js::ci("emotion"),
crate::js::ci("jsx"),
crate::js::ci("module")
))
.expect("HASHED_1")
});
static HASHED_2: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| regex::Regex::new(r"^_[A-Za-z0-9_-]{5,}$").expect("HASHED_2"));
static HASHED_3: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| regex::Regex::new(r"^[a-zA-Z0-9]{6,}$").expect("HASHED_3"));
/// JS: index.mjs#isLikelyHashedClass(c)
pub fn is_likely_hashed_class(c: &str) -> bool {
if c.is_empty() {
return true;
}
if HASHED_1.is_match(c) {
return true;
}
if HASHED_2.is_match(c) {
return true;
}
if HASHED_3.is_match(c) && c.bytes().any(|b| b.is_ascii_digit()) {
return true;
}
false
}
/// JS `[...el.classList]`: the class attribute split on ASCII whitespace,
/// duplicates removed (DOMTokenList semantics), order preserved.
fn class_list(dom: &dyn Dom, el: ElId) -> Vec<String> {
let cls = dom.attr(el, "class").unwrap_or_default();
let mut out: Vec<String> = Vec::new();
for tok in cls.split(|c: char| matches!(c, ' ' | '\t' | '\n' | '\x0C' | '\r')) {
if tok.is_empty() || out.iter().any(|t| t == tok) {
continue;
}
out.push(tok.to_string());
}
out
}
/// JS: index.mjs#buildSelectorSegment(el)
pub fn build_selector_segment(dom: &dyn Dom, el: ElId) -> String {
let tag = tag_lower(dom, el);
let mut sel = tag.clone();
let classes: Vec<String> = class_list(dom, el)
.into_iter()
.filter(|c| !c.starts_with("impeccable-") && !is_likely_hashed_class(c))
.take(2)
.collect();
if !classes.is_empty() {
sel.push('.');
sel.push_str(
&classes
.iter()
.map(|c| dom.css_escape(c))
.collect::<Vec<_>>()
.join("."),
);
}
if let Some(parent) = dom.parent(el) {
match dom.query_all(Some(parent), &format!(":scope > {sel}")) {
Ok(matching) => {
if matching.len() > 1 {
let tag_name = dom.tag_name(el);
let same_type: Vec<ElId> = dom
.children(parent)
.into_iter()
.filter(|c| dom.tag_name(*c) == tag_name)
.collect();
let idx = same_type.iter().position(|c| *c == el).map(|i| i as i64).unwrap_or(-1) + 1;
sel.push_str(&format!(":nth-of-type({idx})"));
}
}
Err(_) => {
let idx = dom
.children(parent)
.iter()
.position(|c| *c == el)
.map(|i| i as i64)
.unwrap_or(-1)
+ 1;
sel = format!("{tag}:nth-child({idx})");
}
}
}
sel
}
/// JS: index.mjs#generateSelector(el)
pub fn generate_selector(dom: &dyn Dom, el: ElId) -> String {
let body = dom.body();
let root = dom.document_element();
if Some(el) == body {
return "body".to_string();
}
if Some(el) == root {
return "html".to_string();
}
let el_id = super::dom::safe_id(dom, el);
if !el_id.is_empty() {
return format!("#{}", dom.css_escape(&el_id));
}
let mut parts: Vec<String> = Vec::new();
let mut current = Some(el);
let mut depth = 0;
const MAX_DEPTH: usize = 10;
while let Some(cur) = current {
if Some(cur) == body || Some(cur) == root || depth >= MAX_DEPTH {
break;
}
parts.insert(0, build_selector_segment(dom, cur));
// JS `current.id` (the raw property, truthy check). Where the
// property is not a string (shadowed by a named control) the object
// is truthy and CSS.escape stringifies it; that garbage-selector case
// is exactly issue #407 for the anchor path and is left as the JS
// does it: id_prop None means the getter returned an element, which
// is truthy → escape("[object HTMLInputElement]").
let cur_id = match dom.id_prop(cur) {
Some(id) => id,
None => "[object HTMLInputElement]".to_string(),
};
if !cur_id.is_empty() {
parts[0] = format!("#{}", dom.css_escape(&cur_id));
break;
}
let try_selector = parts.join(" > ");
if let Ok(matches) = dom.query_all(None, &try_selector) {
if matches.len() == 1 && matches[0] == el {
return try_selector;
}
}
current = dom.parent(cur);
depth += 1;
}
parts.join(" > ")
}
/// JS: index.mjs#isElementHidden(el)
pub fn is_element_hidden(dom: &dyn Dom, el: ElId) -> bool {
if Some(el) == dom.body() || Some(el) == dom.document_element() {
return false;
}
if let Some(v) = dom.check_visibility(el) {
return !v;
}
dom.offset_width(el) == 0.0 && dom.offset_height(el) == 0.0
}
/// The `addVisualContrastResult` decision, split so the JS group map (keyed
/// by live Element) can stay a plain map: first resolve the target element
/// (`None` when the result is not a fail / has no finding / selector
/// unresolvable), then produce the finding to add given the element's
/// existing findings (`None` when a same-type finding exists or the scoped
/// ignore waives it).
pub fn visual_contrast_result_el(dom: &dyn Dom, result: &serde_json::Value) -> Option<ElId> {
if result.get("status").and_then(|v| v.as_str()) != Some("fail") {
return None;
}
if !result.get("finding").map_or(false, truthy) {
return None;
}
let selector = result.get("selector")?;
if !truthy(selector) {
return None;
}
let sel = js_string_or_empty(selector);
match dom.query_one(None, &sel) {
Ok(Some(el)) => Some(el),
_ => None,
}
}
/// JS `a || b` over JSON values, stringified.
fn first_truthy_string(values: &[Option<&serde_json::Value>]) -> Option<String> {
values
.iter()
.flatten()
.find(|v| truthy(v))
.map(|v| js_string_or_empty(v))
}
pub fn visual_contrast_result_finding(
dom: &dyn Dom,
el: ElId,
existing: &[BrowserFinding],
result: &serde_json::Value,
) -> Option<BrowserFinding> {
let finding = result.get("finding")?;
let finding_type = first_truthy_string(&[finding.get("type"), finding.get("id")])
.unwrap_or_else(|| "low-contrast".to_string());
if existing.iter().any(|f| f.type_ == finding_type) {
return None;
}
let detail =
first_truthy_string(&[finding.get("detail"), finding.get("snippet")]).unwrap_or_default();
if scoped_ignore_active(dom, el, &finding_type) {
return None;
}
Some(BrowserFinding::new(finding_type, detail))
}
fn hits(v: Vec<crate::checks::rules::RuleHit>) -> Vec<BrowserFinding> {
v.iter().map(BrowserFinding::from_hit).collect()
}
// ─── value-level suppression (JS: index.mjs#collectBrowserFindings tail) ────
//
// `disabledRules` waives whole rules; this applies the config's remaining
// `ignoreValues` entries, which the CLI filters through
// `isIgnoredFindingValue` (crates/detect config.rs), so a project waiver like
// `overused-font = "geist mono"` reaches the overlay and the extension too.
/// The six rules whose findings carry a matchable value; keep in step with
/// `extract_finding_ignore_value` in crates/detect. Everything else is
/// suppressed by rule or by file scope, both already resolved into
/// `disabledRules` before the scan message was sent.
const DIRECT_VALUE_RULES: &[&str] = &[
"overused-font",
"bounce-easing",
"design-system-font",
"design-system-color",
"design-system-radius",
"design-system-font-size",
];
static EDGE_QUOTE_RE: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| regex::Regex::new(r#"^["']|["']$"#).expect("EDGE_QUOTE_RE"));
static WS_RUN_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new(&format!("[{}]+", crate::js::WS_CHARS)).expect("WS_RUN_RE")
});
static PRIMARY_FONT_RE: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| {
regex::Regex::new(&format!(
"(?i:Primary font):[{}]*([^()\n;]+)",
crate::js::WS_CHARS
))
.expect("PRIMARY_FONT_RE")
});
static GOOGLE_LABEL_RE: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| {
regex::Regex::new(&format!(
"(?i:Google Fonts):[{}]*([^()\n;]+)",
crate::js::WS_CHARS
))
.expect("GOOGLE_LABEL_RE")
});
static FAMILY_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new(&format!(
r#"(?i:font-family)[{ws}]*:[{ws}]*["']?([^'",;\n]+)"#,
ws = crate::js::WS_CHARS
))
.expect("FAMILY_RE")
});
static COLOR_HEX_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new("^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$").expect("COLOR_HEX_RE")
});
static COLOR_RGB_RE: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
regex::Regex::new(r"^rgba?\(([\s\S]*)\)$").expect("COLOR_RGB_RE")
});
static COLOR_CHANNEL_RE: once_cell::sync::Lazy<regex::Regex> =
once_cell::sync::Lazy::new(|| {
regex::Regex::new(r"^(-?\d*\.?\d+)(%)?$").expect("COLOR_CHANNEL_RE")
});
/// JS `_normValue`: trim, drop one edge quote at each end, `+` to space,
/// collapse whitespace runs, lowercase.
pub fn normalize_browser_ignore_value(value: &str) -> String {
let t = crate::js::trim(value);
let t = EDGE_QUOTE_RE.replace_all(t, "");
let t = t.replace('+', " ");
let t = WS_RUN_RE.replace_all(&t, " ");
crate::js::to_lower_case(&t)
}
/// JS `_colorKey`: `design-system-color` compares by color value, not by
/// spelling, because the browser reports computed `rgb(...)` strings while
/// waivers are usually written as hex. Mirrors `colorIgnoreKey` in
/// crates/detect for the hex and `rgb()`/`rgba()` forms; hsl stays CLI-only,
/// as it was in the JS engine.
pub fn browser_color_ignore_key(value: &str) -> String {
let text = crate::js::to_lower_case(crate::js::trim(value));
if let Some(m) = COLOR_HEX_RE.captures(&text) {
let digits = m.get(1).unwrap().as_str();
let expanded: String = if digits.len() <= 4 {
digits.chars().flat_map(|c| [c, c]).collect()
} else {
digits.to_string()
};
let bytes: Vec<u32> = expanded
.as_bytes()
.chunks(2)
.map(|c| u32::from_str_radix(std::str::from_utf8(c).unwrap_or("0"), 16).unwrap_or(0))
.collect();
let a = bytes.get(3).copied().unwrap_or(255);
return format!("{},{},{},{}", bytes[0], bytes[1], bytes[2], a);
}
let Some(m) = COLOR_RGB_RE.captures(&text) else {
return String::new();
};
// JS: body.trim().replace(/\s*\/\s*/g, ' / '), then split on ',' with the
// trailing `a / b` group re-split, or on whitespace when there is no comma.
let body = crate::js::trim(m.get(1).unwrap().as_str()).to_string();
let body = slash_spaced(&body);
let parts: Vec<String> = if body.contains(',') {
let mut parts: Vec<String> = body
.split(',')
.map(|p| crate::js::trim(p).to_string())
.filter(|p| !p.is_empty())
.collect();
if let Some(last) = parts.last().cloned() {
if last.contains('/') {
parts.pop();
parts.extend(
last.split('/')
.map(|p| crate::js::trim(p).to_string())
.filter(|p| !p.is_empty()),
);
}
}
parts
} else {
WS_RUN_RE
.split(&body)
.filter(|p| !p.is_empty() && *p != "/")
.map(|p| p.to_string())
.collect()
};
if parts.len() < 3 || parts.len() > 4 {
return String::new();
}
let channel = |raw: &str, is_alpha: bool| -> Option<f64> {
let m = COLOR_CHANNEL_RE.captures(crate::js::trim(raw))?;
let mut v: f64 = m.get(1).unwrap().as_str().parse().ok()?;
if m.get(2).is_some() {
v = if is_alpha { v / 100.0 } else { v * 2.55 };
}
let max = if is_alpha { 1.0 } else { 255.0 };
if !v.is_finite() || v < 0.0 || v > max {
return None;
}
Some(if is_alpha { v } else { v.round() })
};
let (Some(r), Some(g), Some(b)) = (
channel(&parts[0], false),
channel(&parts[1], false),
channel(&parts[2], false),
) else {
return String::new();
};
let a = match parts.get(3) {
None => 1.0,
Some(p) => match channel(p, true) {
Some(v) => v,
None => return String::new(),
},
};
format!(
"{},{},{},{}",
crate::js::number_to_string(r),
crate::js::number_to_string(g),
crate::js::number_to_string(b),
crate::js::number_to_string((a * 255.0).round())
)
}
/// JS `.replace(/\s*\/\s*/g, ' / ')`.
fn slash_spaced(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let is_ws = |c: char| c.is_whitespace() || c == '\u{feff}';
let mut i = 0;
while i < chars.len() {
let start = i;
while i < chars.len() && is_ws(chars[i]) {
i += 1;
}
if i < chars.len() && chars[i] == '/' {
i += 1;
while i < chars.len() && is_ws(chars[i]) {
i += 1;
}
out.push_str(" / ");
continue;
}
i = start;
out.push(chars[i]);
i += 1;
}
out
}
/// JS `_findingValue`: the value a finding of a value-scoped rule offers to a
/// waiver, or empty when it offers none.
fn browser_finding_ignore_value(f: &BrowserFinding) -> String {
if !DIRECT_VALUE_RULES.contains(&f.type_.as_str()) {
return String::new();
}
if let Some(direct) = f.ignore_value.as_deref().filter(|s| !s.is_empty()) {
return normalize_browser_ignore_value(direct);
}
// The CLI routes bounce-easing through extractMotionIgnoreValue and never
// the font regexes; without a direct ignoreValue there is no value to
// match, so do not invent one from unrelated CSS text.
if f.type_ == "bounce-easing" {
return String::new();
}
// The design-system checks set `ignoreValue` on their findings; the detail
// fallback catches overused-font, whose value lives in its sentence.
for re in [&*PRIMARY_FONT_RE, &*GOOGLE_LABEL_RE, &*FAMILY_RE] {
if let Some(m) = re.captures(&f.detail) {
return normalize_browser_ignore_value(m.get(1).unwrap().as_str());
}
}
String::new()
}
/// JS `_valueIgnored`.
fn browser_value_ignored(f: &BrowserFinding, entries: &[(String, String)]) -> bool {
let value = browser_finding_ignore_value(f);
if value.is_empty() {
return false;
}
entries.iter().any(|(rule, entry_value)| {
rule == &f.type_
&& (entry_value == &value
|| (f.type_ == "design-system-color" && {
let key = browser_color_ignore_key(entry_value);
!key.is_empty() && key == browser_color_ignore_key(&value)
}))
})
}
/// JS: index.mjs#collectBrowserFindings()
pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> CollectResult {
use super::element_checks as ec;
use super::page_checks as pc;
use super::quality as q;
use super::text_collectors as tc;
// A page matched by detector.ignoreFiles is waived wholesale: every scan
// stage answers empty so the badge and toast read zero. Mirrors
// shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live
// overlay resolves the globs per page (live-browser-ignores.js) and
// forwards the verdict as config.skipScan. JS: index.mjs#skipScanActive().
if config.extension_mode && config.skip_scan {
return CollectResult { groups: Vec::new(), page_level: Vec::new() };
}
let mut groups: Vec<FindingGroup> = Vec::new();
let mut page_level: Vec<BrowserFinding> = Vec::new();
let disabled: Vec<String> = if config.extension_mode {
config.disabled_rules.clone()
} else {
Vec::new()
};
let rule_ok = |id: &str| disabled.is_empty() || !disabled.iter().any(|d| d == id);
let design_system = browser_design_system_config(config);
let mut design_seen = DesignSeen::default();
// The AI palette is read over the whole page: neon ink on a near-black
// ground waits here until a second tell hue turns up somewhere, so one
// deliberate accent stays an accent (REN-405).
let mut palette_tells: Vec<ec::TellHue> = Vec::new();
let mut palette_ink: Vec<(ElId, BrowserFinding)> = Vec::new();
let body = dom.body();
let root = dom.document_element();
// JS `document.body` may be null on a bare document; every
// `addBrowserFindings(groupMap, document.body, ...)` then keys on null.
// Elements never equal null, so the page-level groups collapse under
// handle 0 the same way they collapse under null.
let body_key = body.unwrap_or(0);
for el in dom.query_all(None, "*").unwrap_or_default() {
if super::dom::closest_or_none(
dom,
el,
".impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip",
)
.is_some()
{
continue;
}
let el_id = super::dom::safe_id(dom, el);
if el_id.starts_with("claude-") || el_id.starts_with("cic-") {
continue;
}
if super::dom::closest_or_none(dom, el, "[id^=\"impeccable-live-\"]").is_some() {
continue;
}
if Some(el) == body || Some(el) == root {
continue;
}
let mut findings: Vec<BrowserFinding> = Vec::new();
findings.extend(hits(ec::check_element_borders_dom(dom, el)));
findings.extend(hits(ec::check_element_pseudo_stripe_dom(dom, el)));
findings.extend(hits(ec::check_element_stripe_child_dom(dom, el)));
findings.extend(hits(ec::check_element_colors_dom(dom, el)));
findings.extend(hits(ec::check_element_motion_dom(dom, el)));
findings.extend(hits(ec::check_element_glow_dom(dom, el)));
let palette = ec::check_element_ai_palette_dom(dom, el);
// An ignored subtree gets no vote in the page-wide reading. A cyan
// tell inside `data-impeccable-ignore="ai-color-palette"` would
// otherwise open the two-hue gate and charge neon ink somewhere else
// on the page that nobody waived — ignored content changing the
// result for content that was not ignored.
if !scoped_ignore_active(dom, el, "ai-color-palette") {
palette_tells.extend(palette.tells.iter().copied());
}
if let Some(ink) = palette.ink {
palette_ink.push((el, BrowserFinding::new(ink.id, ink.snippet)));
}
findings.extend(hits(palette.hits));
findings.extend(hits(ec::check_element_radial_spotlight_dom(dom, el)));
findings.extend(hits(ec::check_element_icon_tile_dom(dom, el)));
findings.extend(hits(ec::check_element_italic_serif_dom(dom, el)));
findings.extend(hits(q::check_element_quality_dom(dom, el, config)));
findings.extend(hits(ec::check_element_oversized_h1_dom(dom, el)));
findings.extend(hits(ec::check_element_clipped_overflow_dom(dom, el)));
findings.extend(hits(ec::check_element_gpt_border_shadow_dom(dom, el)));
findings.extend(hits(ec::check_element_text_overflow_dom(dom, el)));
findings.extend(ec::check_element_blinking_cursor_dom(dom, el));
findings.extend(check_element_design_system_dom(
dom,
el,
design_system.as_ref(),
&mut design_seen,
));
// Rule-pack element rules run last, so the built-in findings for this
// element keep their order and their position in the group.
if let Some(pack) = config.rule_pack {
findings.extend(pack.check_element_dom(dom, el));
}
let findings: Vec<BrowserFinding> =
findings.into_iter().filter(|f| rule_ok(&f.type_)).collect();
add_browser_findings(dom, &mut groups, el, findings);
// Hero eyebrow: highlight the previous sibling instead.
let eyebrow: Vec<BrowserFinding> = hits(ec::check_element_hero_eyebrow_dom(dom, el))
.into_iter()
.filter(|f| rule_ok(&f.type_))
.collect();
if !eyebrow.is_empty() {
if let Some(prev) = dom.previous_element_sibling(el) {
add_browser_findings(dom, &mut groups, prev, eyebrow);
}
}
}
// Two different tell hues on one page is the palette; one is an accent.
if palette_tells.iter().any(|t| *t == ec::TellHue::Cyan)
&& palette_tells.iter().any(|t| *t == ec::TellHue::Purple)
{
for (el, finding) in palette_ink {
if rule_ok(&finding.type_) {
add_browser_findings(dom, &mut groups, el, vec![finding]);
}
}
}
let page_pass = |groups: &mut Vec<FindingGroup>, page_level: &mut Vec<BrowserFinding>, list: Vec<BrowserFinding>| {
let list: Vec<BrowserFinding> = list.into_iter().filter(|f| rule_ok(&f.type_)).collect();
if !list.is_empty() {
page_level.extend(list.iter().cloned());
add_browser_findings(dom, groups, body_key, list);
}
};
let el_pass = |groups: &mut Vec<FindingGroup>, list: Vec<super::ElFinding>| {
for f in list {
if !rule_ok(&f.finding.type_) {
continue;
}
let target = f.el.unwrap_or(body_key);
add_browser_findings(
dom,
groups,
target,
vec![BrowserFinding::new(f.finding.type_.clone(), f.finding.detail.clone())],
);
}
};
page_pass(
&mut groups,
&mut page_level,
check_browser_design_system_sources(dom, design_system.as_ref(), &mut design_seen),
);
page_pass(&mut groups, &mut page_level, pc::check_typography(dom));
el_pass(&mut groups, tc::check_kicker_above_heading_dom(dom, design_system.as_ref()));
page_pass(&mut groups, &mut page_level, hits(tc::check_numbered_section_labels_dom(dom)));
page_pass(&mut groups, &mut page_level, hits(tc::check_repeated_container_text_dom(dom)));
page_pass(&mut groups, &mut page_level, hits(tc::check_em_dash_overuse_dom(dom)));
el_pass(&mut groups, pc::check_layout(dom));
el_pass(&mut groups, pc::check_heading_rhythm_dom(dom));
el_pass(&mut groups, pc::check_edge_flush_cards_dom(dom));
el_pass(&mut groups, pc::check_text_occlusion_dom(dom));
el_pass(&mut groups, pc::check_first_viewport_column_overflow_dom(dom));
page_pass(&mut groups, &mut page_level, q::check_page_quality_dom(dom));
page_pass(&mut groups, &mut page_level, hits(pc::check_cream_palette(dom)));
page_pass(&mut groups, &mut page_level, scoped_html_pattern_findings(dom));
// Rule-pack page rules run after every built-in page pass, through the
// same attribution as the built-in checks that name their own element.
if let Some(pack) = config.rule_pack {
el_pass(&mut groups, pack.check_page_dom(dom));
}
// Value-level suppression runs last, over everything the passes produced,
// so a project waiver covers a rule pack's findings the same way it covers
// the built-in ones. JS: index.mjs#collectBrowserFindings() tail.
let disabled_values: Vec<(String, String)> = if config.extension_mode {
config
.disabled_values
.iter()
.map(|e| {
(
crate::js::to_lower_case(crate::js::trim(&e.rule)),
normalize_browser_ignore_value(&e.value),
)
})
.collect()
} else {
Vec::new()
};
if !disabled_values.is_empty() {
for group in groups.iter_mut() {
group.findings.retain(|f| !browser_value_ignored(f, &disabled_values));
}
groups.retain(|g| !g.findings.is_empty());
page_level.retain(|f| !browser_value_ignored(f, &disabled_values));
}
CollectResult { groups, page_level }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::browser::fake_dom::FakeDom;
use serde_json::json;
fn ds_config(v: serde_json::Value) -> BrowserConfig {
BrowserConfig {
design_system: Some(v),
..Default::default()
}
}
#[test]
fn design_system_config_parses_and_gates_on_present() {
assert!(browser_design_system_config(&ds_config(json!({ "present": false }))).is_none());
let ds = browser_design_system_config(&ds_config(json!({
"present": true,
"hasFonts": true, "allowedFonts": ["Inter", "'Inter'", "Space+Grotesk", ""],
"hasColors": true, "allowedColors": [{ "r": 10, "g": 20, "b": 30 }, { "r": "x" }],
"hasRadii": true, "allowedRadii": [8, "12", "abc"],
"hasPillRadius": true
})))
.unwrap();
assert_eq!(ds.allowed_fonts, vec!["inter", "space grotesk"]);
assert_eq!(ds.allowed_colors.len(), 1);
assert_eq!(ds.allowed_radii, vec![8.0, 12.0]);
assert!(ds.has_fonts && ds.has_colors && ds.has_radii && ds.has_pill_radius);
// hasFonts without any usable font reads false.
let ds2 = browser_design_system_config(&ds_config(json!({
"present": true, "hasFonts": true, "allowedFonts": []
})))
.unwrap();
assert!(!ds2.has_fonts && !ds2.has_colors && !ds2.has_radii);
}
#[test]
fn primary_font_and_normalization() {
assert_eq!(browser_primary_font("\"Inter\", system-ui, sans-serif"), "inter");
assert_eq!(browser_primary_font("system-ui, sans-serif"), "");
assert_eq!(browser_primary_font("system-ui, Roboto"), "roboto");
assert_eq!(browser_primary_font("var(--font)"), "");
assert_eq!(normalize_browser_font_name(" 'Space+Grotesk' "), "space grotesk");
}
#[test]
fn design_element_findings_and_seen_dedupe() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let p = d.add(Some(body), "p");
d.add_text(p, "Hello world");
d.set_styles(
p,
&[
("fontFamily", "Comic Sans MS, cursive"),
("color", "rgb(255, 0, 0)"),
("backgroundColor", "rgba(0, 0, 0, 0)"),
("borderTopWidth", "0px"),
("borderRightWidth", "0px"),
("borderBottomWidth", "1px"),
("borderLeftWidth", "0px"),
("borderBottomColor", "rgb(10, 20, 30)"),
("outlineWidth", "0px"),
("borderRadius", "8px 3px / 2px"),
],
);
d.el_mut(p).check_visibility = Some(true);
let ds = browser_design_system_config(&ds_config(json!({
"present": true,
"hasFonts": true, "allowedFonts": ["Inter"],
"hasColors": true, "allowedColors": [{ "r": 10, "g": 20, "b": 30 }],
"hasRadii": true, "allowedRadii": [8], "hasPillRadius": false
})))
.unwrap();
let mut seen = DesignSeen::default();
let f = check_element_design_system_dom(&d, p, Some(&ds), &mut seen);
let types: Vec<&str> = f.iter().map(|x| x.type_.as_str()).collect();
assert_eq!(
types,
vec!["design-system-font", "design-system-color", "design-system-radius", "design-system-radius"]
);
assert_eq!(
f[0].detail,
"p \"Hello world\" uses comic sans ms; not declared in DESIGN.md typography"
);
assert_eq!(f[0].ignore_value.as_deref(), Some("comic sans ms"));
assert_eq!(
f[1].detail,
"text color rgb(255, 0, 0) on p \"Hello world\" is outside DESIGN.md colors"
);
assert_eq!(f[2].detail, "border-radius 3px on p \"Hello world\" is outside the DESIGN.md rounded scale");
assert_eq!(f[3].ignore_value.as_deref(), Some("2px"));
// Second element with the same offenders adds nothing.
let q = d.add(Some(body), "p");
d.add_text(q, "Again");
for (k, v) in d.el(p).styles.clone() {
d.set_style(q, &k, &v);
}
d.el_mut(q).check_visibility = Some(true);
assert!(check_element_design_system_dom(&d, q, Some(&ds), &mut seen).is_empty());
// Hidden elements are skipped.
d.el_mut(q).check_visibility = Some(false);
let mut seen2 = DesignSeen::default();
assert!(check_element_design_system_dom(&d, q, Some(&ds), &mut seen2).is_empty());
}
#[test]
fn google_font_sources() {
let mut d = FakeDom::new();
let (html, _body) = d.with_page();
let head = d.add(Some(html), "head");
let link = d.add(Some(head), "link");
d.set_attr(
link,
"href",
"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&family=Inter&family=Bad%ZZ",
);
d.add_selector(link, "link[href*=\"fonts.googleapis.com/css\"]");
let ds = browser_design_system_config(&ds_config(json!({
"present": true, "hasFonts": true, "allowedFonts": ["Inter"]
})))
.unwrap();
let mut seen = DesignSeen::default();
let f = check_browser_design_system_sources(&d, Some(&ds), &mut seen);
assert_eq!(f.len(), 2);
assert_eq!(
f[0].detail,
"Google Fonts: Space Grotesk is not declared in DESIGN.md typography"
);
assert_eq!(f[0].ignore_value.as_deref(), Some("Space Grotesk"));
// malformed escape: decodeURIComponent throws, the raw family stays
assert_eq!(f[1].ignore_value.as_deref(), Some("Bad%ZZ"));
assert_eq!(decode_uri_component("caf%C3%A9"), Some("café".to_string()));
assert_eq!(decode_uri_component("%E2%82"), None);
}
#[test]
fn selector_generation() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let main = d.add(Some(body), "main");
d.set_attr(main, "id", "app");
let sec = d.add(Some(main), "section");
d.set_attr(sec, "class", "hero css-1a2b3c impeccable-x hero-inner");
let a = d.add(Some(sec), "p");
let b = d.add(Some(sec), "p");
d.set_attr(b, "class", "lead");
// The fake matches `:scope > p` for both paragraphs and the composed
// selectors document-wide.
d.add_selector(a, ":scope > p");
d.add_selector(b, ":scope > p");
d.add_selector(b, ":scope > p.lead");
assert_eq!(generate_selector(&d, main), "#app");
assert_eq!(generate_selector(&d, body), "body");
// p without class: two `:scope > p` matches → nth-of-type; the
// partial `section.hero.hero-inner > p:nth-of-type(1)` matches nothing
// in the fake, so the walk continues to the #app anchor.
assert_eq!(
generate_selector(&d, a),
"#app > section.hero.hero-inner > p:nth-of-type(1)"
);
// Unique partial selector returns early.
d.add_selector(b, "p.lead");
assert_eq!(generate_selector(&d, b), "p.lead");
assert!(is_likely_hashed_class("css-1a2b3c"));
assert!(is_likely_hashed_class("_2x4hG"));
assert!(is_likely_hashed_class("a1b2c3"));
assert!(!is_likely_hashed_class("hero"));
assert!(!is_likely_hashed_class("abcdefg"));
}
/// REN-405. Northwind's Slate system: near-black ground, light ink, one
/// teal accent. The accent lit 18 places on a page with nothing wrong with
/// it. It stays quiet until the page shows the other half of the palette.
#[test]
fn one_accent_hue_on_dark_is_not_the_ai_palette() {
let build = |gradient: bool| {
let mut d = FakeDom::new();
let (_html, body) = d.with_page();
d.set_style(body, "backgroundColor", "rgb(15, 18, 17)");
d.set_rect(body, 0.0, 0.0, 1440.0, 900.0);
for i in 0..3 {
let a = d.add(Some(body), "a");
d.add_text(a, "Open the ledger");
d.set_rect(a, 40.0, 40.0 + 30.0 * (i as f64), 160.0, 20.0);
d.set_styles(a, &[("color", "rgb(47, 184, 166)")]);
}
if gradient {
let hero = d.add(Some(body), "div");
d.set_rect(hero, 0.0, 200.0, 1440.0, 320.0);
d.set_style(
hero,
"backgroundImage",
"linear-gradient(135deg, rgb(124, 58, 237) 0%, rgb(168, 85, 247) 100%)",
);
}
d
};
let ids = |d: &FakeDom| {
collect_browser_findings(d, &BrowserConfig::default())
.groups
.iter()
.flat_map(|g| g.findings.iter())
.filter(|f| f.type_ == "ai-color-palette")
.map(|f| f.detail.clone())
.collect::<Vec<_>>()
};
// One teal accent on near-black: an accent.
assert_eq!(ids(&build(false)), Vec::<String>::new());
// The same accent beside a purple gradient: the palette, and every
// place it shows is named.
assert_eq!(
ids(&build(true)),
vec![
"Purple/violet gradient background".to_string(),
"Cyan neon text on dark background".to_string(),
"Cyan neon text on dark background".to_string(),
"Cyan neon text on dark background".to_string(),
]
);
}
#[test]
fn skip_scan_empties_the_collect_pass() {
// JS: index.mjs#skipScanActive() — an ignoreFiles-waived page answers
// every scan stage with the empty shape (upstream commit 00095adb).
let make_dom = || {
let mut d = FakeDom::new();
let (html, _body) = d.with_page();
let head = d.add(Some(html), "head");
let link = d.add(Some(head), "link");
d.set_attr(link, "href", "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700");
d.add_selector(link, "link[href*=\"fonts.googleapis.com/css\"]");
d
};
let ds = json!({ "present": true, "hasFonts": true, "allowedFonts": ["Inter"] });
let base = BrowserConfig { extension_mode: true, design_system: Some(ds), ..Default::default() };
// The same page flags without skipScan...
let d = make_dom();
let out = collect_browser_findings(&d, &base);
assert!(!out.groups.is_empty());
assert!(!out.page_level.is_empty());
// ...and answers empty with it.
let skip = BrowserConfig { skip_scan: true, ..base.clone() };
let d = make_dom();
let out = collect_browser_findings(&d, &skip);
assert!(out.groups.is_empty());
assert!(out.page_level.is_empty());
// The guard is extension-mode only, exactly as the JS reads it.
let non_ext = BrowserConfig { extension_mode: false, skip_scan: true, ..skip };
let d = make_dom();
let out = collect_browser_findings(&d, &non_ext);
assert!(!out.groups.is_empty());
}
#[test]
fn skip_scan_covers_every_stage_of_the_collect_pass() {
// The visual-contrast stage is not part of this pass (it runs in the
// page, 50-scan.js), but everything the core produces has to be gone:
// element groups, page-level findings, and the rule pack.
let mut d = FakeDom::new();
let (html, body) = d.with_page();
let head = d.add(Some(html), "head");
let link = d.add(Some(head), "link");
d.set_attr(link, "href", "https://fonts.googleapis.com/css2?family=Poppins");
d.add_selector(link, "link[href*=\"fonts.googleapis.com/css\"]");
let p = d.add(Some(body), "p");
d.add_text(p, "Hello");
d.set_styles(p, &[("fontFamily", "Poppins, sans-serif")]);
d.el_mut(p).check_visibility = Some(true);
let ds = json!({ "present": true, "hasFonts": true, "allowedFonts": ["Inter"] });
let cfg = BrowserConfig {
extension_mode: true,
skip_scan: true,
design_system: Some(ds),
..Default::default()
};
let out = collect_browser_findings(&d, &cfg);
assert!(out.groups.is_empty());
assert!(out.page_level.is_empty());
}
/// The waiver plumbing the live overlay depends on: `disabledValues`
/// entries resolved by live-browser-ignores.js suppress the matching
/// findings where the findings are assembled, since the overlay draws its
/// markers from what this pass returns. JS: index.mjs#collectBrowserFindings
/// value-level suppression (issue #639).
#[test]
fn disabled_values_suppress_matching_findings() {
let make_dom = || {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let p = d.add(Some(body), "p");
d.add_text(p, "Hello");
d.set_styles(
p,
&[
("fontFamily", "Poppins, sans-serif"),
("color", "rgb(255, 0, 0)"),
("backgroundColor", "rgba(0, 0, 0, 0)"),
],
);
d.el_mut(p).check_visibility = Some(true);
d
};
let ds = json!({
"present": true,
"hasFonts": true, "allowedFonts": ["Inter"],
"hasColors": true, "allowedColors": [{ "r": 10, "g": 20, "b": 30 }]
});
let base = BrowserConfig {
extension_mode: true,
design_system: Some(ds),
..Default::default()
};
let types = |out: &CollectResult| -> Vec<String> {
out.groups
.iter()
.flat_map(|g| g.findings.iter().map(|f| f.type_.clone()))
.collect()
};
let unfiltered = collect_browser_findings(&make_dom(), &base);
assert!(types(&unfiltered).contains(&"design-system-font".to_string()));
assert!(types(&unfiltered).contains(&"design-system-color".to_string()));
// A font waiver drops its finding and leaves the unrelated one.
let font_waived = BrowserConfig {
disabled_values: vec![DisabledValue {
rule: "design-system-font".into(),
value: "Poppins".into(),
}],
..base.clone()
};
let out = collect_browser_findings(&make_dom(), &font_waived);
assert!(!types(&out).contains(&"design-system-font".to_string()));
assert!(types(&out).contains(&"design-system-color".to_string()));
// Color waivers match by value, not by spelling: the browser reports
// computed rgb(...) and the waiver is written as hex.
let color_waived = BrowserConfig {
disabled_values: vec![DisabledValue {
rule: "design-system-color".into(),
value: "#ff0000".into(),
}],
..base.clone()
};
let out = collect_browser_findings(&make_dom(), &color_waived);
assert!(!types(&out).contains(&"design-system-color".to_string()));
assert!(types(&out).contains(&"design-system-font".to_string()));
// A waiver for another rule's value changes nothing.
let unrelated = BrowserConfig {
disabled_values: vec![DisabledValue {
rule: "design-system-font".into(),
value: "Inter".into(),
}],
..base.clone()
};
let out = collect_browser_findings(&make_dom(), &unrelated);
assert_eq!(types(&out), types(&unfiltered));
// Outside extension mode the config is whatever the page set, so the
// list is ignored, exactly as the JS read it.
let non_ext = BrowserConfig {
extension_mode: false,
..font_waived
};
let out = collect_browser_findings(&make_dom(), &non_ext);
assert!(types(&out).contains(&"design-system-font".to_string()));
}
#[test]
fn disabled_values_parse_and_normalize_like_the_js() {
// JS `.filter(e => e && typeof e === 'object' && e.rule && e.value)`:
// a hand-edited __IMPECCABLE_CONFIG__ drops bad entries, it does not
// fail the whole config.
let cfg: BrowserConfig = serde_json::from_str(
r##"{"extensionMode":true,"disabledValues":[
{"rule":"overused-font","value":"Geist+Mono"},
{"rule":"design-system-font"},
{"value":"orphan"},
"nope",
{"rule":"design-system-color","value":"#FFF"}
]}"##,
)
.unwrap();
assert_eq!(cfg.disabled_values.len(), 2);
assert_eq!(cfg.disabled_values[0].value, "Geist+Mono");
// A config with no disabledValues at all still parses.
let bare: BrowserConfig = serde_json::from_str(r#"{"extensionMode":true}"#).unwrap();
assert!(bare.disabled_values.is_empty());
let junk: BrowserConfig =
serde_json::from_str(r#"{"disabledValues":"not an array"}"#).unwrap();
assert!(junk.disabled_values.is_empty());
assert_eq!(normalize_browser_ignore_value(" \"Geist+Mono\" "), "geist mono");
assert_eq!(browser_color_ignore_key("#fff"), "255,255,255,255");
assert_eq!(browser_color_ignore_key("rgb(255, 0, 0)"), "255,0,0,255");
assert_eq!(
browser_color_ignore_key("rgb(255 255 255 / 50%)"),
"255,255,255,128"
);
// hsl stays CLI-only in the browser matcher, as it was in the JS.
assert_eq!(browser_color_ignore_key("hsl(0, 0%, 100%)"), "");
assert_eq!(browser_color_ignore_key("not a color"), "");
// bounce-easing without a direct ignoreValue offers no value: the CLI
// routes it through the motion extractor and never the font regexes.
let bounce = BrowserFinding::new("bounce-easing", "font-family: Poppins");
assert!(browser_finding_ignore_value(&bounce).is_empty());
let mut carried = BrowserFinding::new("overused-font", "Primary font: Poppins (etc)");
assert_eq!(browser_finding_ignore_value(&carried), "poppins");
carried.ignore_value = Some("Space Grotesk".into());
assert_eq!(browser_finding_ignore_value(&carried), "space grotesk");
// A rule outside the six offers nothing to match.
assert!(browser_finding_ignore_value(&BrowserFinding::new("glow-effect", "x")).is_empty());
}
#[test]
fn html_pattern_query_strips_pseudos_and_dangling_commas() {
assert_eq!(html_pattern_query(".card::before"), Some(".card".to_string()));
assert_eq!(html_pattern_query(".a:hover, .b::after"), Some(".a, .b".to_string()));
assert_eq!(html_pattern_query("::before, ::after"), None);
assert_eq!(html_pattern_query(".x:not(.y)::before"), Some(".x".to_string()));
assert_eq!(html_pattern_query(".a::before,"), Some(".a".to_string()));
}
/// An ignored subtree does not get to open the page-wide palette gate.
/// `ai-color-palette` holds neon ink until a second tell hue turns up
/// somewhere on the page; a cyan tell inside a
/// `data-impeccable-ignore="ai-color-palette"` subtree used to count
/// toward that, so waiving one component charged an unrelated one.
#[test]
fn ignored_colors_do_not_contribute_tell_hues() {
let build = |ignore: bool| {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
d.set_style(body, "backgroundColor", "rgb(5, 6, 10)");
// The waived component: cyan neon ink on near-black.
let demo = d.add(Some(body), "div");
d.set_style(demo, "backgroundColor", "rgb(5, 6, 10)");
if ignore {
d.set_attr(demo, "data-impeccable-ignore", "ai-color-palette");
}
let cyan = d.add(Some(demo), "span");
d.add_text(cyan, "Terminal output");
d.set_style(cyan, "color", "rgb(34, 238, 238)");
d.set_style(cyan, "backgroundColor", "rgba(0, 0, 0, 0)");
d.el_mut(cyan).check_visibility = Some(true);
// Somewhere else on the page, and waived by nobody.
let card = d.add(Some(body), "div");
d.set_style(card, "backgroundColor", "rgb(5, 6, 10)");
let purple = d.add(Some(card), "span");
d.add_text(purple, "Upgrade");
d.set_style(purple, "color", "rgb(180, 60, 245)");
d.set_style(purple, "backgroundColor", "rgba(0, 0, 0, 0)");
d.el_mut(purple).check_visibility = Some(true);
d
};
let charged = |d: &FakeDom| -> Vec<String> {
collect_browser_findings(d, &BrowserConfig::default())
.groups
.iter()
.flat_map(|g| g.findings.iter().map(|f| f.type_.clone()))
.filter(|t| t == "ai-color-palette")
.collect()
};
// Two tell hues, neither waived: the palette is the page's.
assert_eq!(charged(&build(false)).len(), 2);
// The cyan half waived: one tell hue is an accent, and the purple ink
// outside the ignored subtree is not charged either.
assert!(charged(&build(true)).is_empty());
}
#[test]
fn scoped_ignore_and_visual_merge() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let wrap = d.add(Some(body), "div");
d.set_attr(wrap, "data-impeccable-ignore", "low-contrast, glow-effect");
let p = d.add(Some(wrap), "p");
d.add_selector(p, "#t");
assert!(scoped_ignore_active(&d, p, "LOW-CONTRAST"));
assert!(!scoped_ignore_active(&d, p, "side-tab"));
let result = json!({
"status": "fail", "selector": "#t",
"finding": { "id": "low-contrast", "snippet": "browser contrast 2.0:1" }
});
assert_eq!(visual_contrast_result_el(&d, &result), Some(p));
assert!(visual_contrast_result_finding(&d, p, &[], &result).is_none());
d.set_attr(wrap, "data-impeccable-ignore", "glow-effect");
let f = visual_contrast_result_finding(&d, p, &[], &result).unwrap();
assert_eq!((f.type_.as_str(), f.detail.as_str()), ("low-contrast", "browser contrast 2.0:1"));
let existing = vec![BrowserFinding::new("low-contrast", "x")];
assert!(visual_contrast_result_finding(&d, p, &existing, &result).is_none());
let pass = json!({ "status": "pass", "selector": "#t", "finding": null });
assert_eq!(visual_contrast_result_el(&d, &pass), None);
}
}
#[cfg(test)]
mod pseudo_host_tests {
use super::pseudo_element_host_selector as host;
#[test]
fn pseudo_element_hosts() {
// No pseudo-element: the full selector stays queryable as written.
assert_eq!(host(".card"), None);
assert_eq!(host("a:hover"), None);
assert_eq!(host("li:not(.x)"), None);
// Attached and hostless pseudo-elements.
assert_eq!(host(".card::before"), Some(".card".to_string()));
assert_eq!(host("main > ::before"), Some("main > *".to_string()));
assert_eq!(host("::after"), Some("*".to_string()));
// The legacy one-colon spellings only.
assert_eq!(host(".c:before"), Some(".c".to_string()));
assert_eq!(host(".c:focus"), None);
// Functional pseudo-elements consume their argument list.
assert_eq!(host("p::part(label) span"), Some("p span".to_string()));
// Literals are preserved, colons inside them are not pseudo starts.
assert_eq!(host("[data-x=\"a::b\"]"), None);
assert_eq!(
host("[data-x=\"a::b\"]::before"),
Some("[data-x=\"a::b\"]".to_string())
);
// A pseudo-class keeps its colon while a pseudo-element resolves.
assert_eq!(host("a:hover::after"), Some("a:hover".to_string()));
// Values recorded from the JS on origin/main (#709).
assert_eq!(host(".a::before, .b"), Some(".a, .b".to_string()));
assert_eq!(host(".a::before,"), Some(".a".to_string()));
assert_eq!(host("::before ::after"), Some("* *".to_string()));
assert_eq!(host(r"\:esc::before"), Some(r"\:esc".to_string()));
assert_eq!(host("a::before("), Some("a".to_string()));
assert_eq!(host("div::first-line"), Some("div".to_string()));
}
}