Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/core/src/browser/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1350,6 +1350,7 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
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)));
Expand Down
92 changes: 91 additions & 1 deletion crates/core/src/browser/element_checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ 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, check_placeholder_colors, is_emoji_only_text, BorderOpts,
check_italic_serif, check_motion, check_placeholder_colors, check_stripe_child,
is_emoji_only_text, BorderOpts,
ColorOpts, GlowOpts, HeroEyebrowOpts, IconTileOpts, ItalicSerifOpts, MotionOpts, RuleHit,
Sides, HEADING_TAGS,
};
Expand Down Expand Up @@ -368,6 +369,57 @@ pub fn check_element_pseudo_stripe_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit>
findings
}

const STRIPE_CHILD_SKIP: &str = "nav, blockquote, pre, table, button, a, select, progress, meter, [role=\"progressbar\"], [role=\"slider\"], [role=\"scrollbar\"], [role=\"separator\"], [role=\"tablist\"]";

/// JS: checks.mjs#checkElementStripeChildDOM(el)
pub fn check_element_stripe_child_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
let tag = tag_lower(dom, el);
if tag != "div" && tag != "span" {
return Vec::new();
}
let Some(host) = dom.parent(el) else {
return Vec::new();
};
let host_tag = tag_lower(dom, host);
if host_tag == "body" || host_tag == "html" {
return Vec::new();
}
if !dom.children(el).is_empty() {
return Vec::new();
}
if !js::trim(&collapse_ws(&dom.text_content(el))).is_empty() {
return Vec::new();
}
if closest_or_none(dom, el, STRIPE_CHILD_SKIP).is_some() {
return Vec::new();
}
Comment thread
pbakaus marked this conversation as resolved.
if !is_rendered_for_browser_rule(dom, el) {
return Vec::new();
}
if is_tab_context_element(dom, el) || is_status_context_element(dom, el) {
return Vec::new();
}
let host_rect = dom.rect(host);
if host_rect.width < 40.0 || host_rect.height < 20.0 {
return Vec::new();
}
let child_rect = dom.rect(el);
if child_rect.height < host_rect.height - 44.0 || child_rect.height < host_rect.height * 0.5 {
return Vec::new();
}
let hugs = |v: f64| v.is_finite() && v.abs() <= 3.0;
let edge = if hugs(child_rect.left - host_rect.left) {
Some("left")
} else if hugs(host_rect.right - child_rect.right) {
Some("right")
} else {
None
};
let width = child_rect.width;
let bg = parse_rgb_or_any(&dom.style(el, "backgroundColor"));
check_stripe_child(&class_selector(dom, el), width, edge, bg)
}

/// JS: checks.mjs#readPseudoSurfaceDOM(el, rect)
pub fn read_pseudo_surface_dom(dom: &dyn Dom, el: ElId, rect: &Rect) -> Option<Rgba> {
for which in PSEUDOS {
Expand Down Expand Up @@ -1408,6 +1460,44 @@ mod tests {
assert!(check_element_pseudo_stripe_dom(&d, card).is_empty());
}

#[test]
fn stripe_child_flags_left_edge_and_skips_neutral_text_and_tab_context() {
let (mut d, body) = page();
let host = d.add(Some(body), "div");
visible(&mut d, host);
d.set_attr(host, "class", "card");
d.set_rect(host, 0.0, 0.0, 300.0, 100.0);
let stripe = d.add(Some(host), "div");
visible(&mut d, stripe);
d.set_rect(stripe, 0.0, 0.0, 4.0, 100.0);
d.set_styles(
stripe,
&[
("backgroundColor", "rgb(245, 158, 11)"),
("width", "4px"),
("height", "100px"),
],
);
let hits = check_element_stripe_child_dom(&d, stripe);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "side-tab");
assert_eq!(hits[0].snippet, "div — 4px stripe child (left)");
d.set_styles(stripe, &[("backgroundColor", "rgb(120, 120, 120)")]);
assert!(check_element_stripe_child_dom(&d, stripe).is_empty());
let stripe_text = d.add(Some(host), "div");
visible(&mut d, stripe_text);
d.set_rect(stripe_text, 4.0, 0.0, 4.0, 100.0);
d.set_styles(
stripe_text,
&[("backgroundColor", "rgb(245, 158, 11)")],
);
d.add_text(stripe_text, "x");
assert!(check_element_stripe_child_dom(&d, stripe_text).is_empty());
d.set_styles(stripe, &[("backgroundColor", "rgb(245, 158, 11)")]);
d.set_attr(host, "class", "card is-active");
assert!(check_element_stripe_child_dom(&d, stripe).is_empty());
}

#[test]
fn placeholder_low_contrast_flags() {
let (mut d, body) = page();
Expand Down
33 changes: 33 additions & 0 deletions crates/core/src/checks/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,39 @@ pub fn check_borders(
findings
}

/// Pure gate for dedicated stripe-child side-tabs (empty narrow chromatic
/// `div`/`span` at a card edge).
pub fn check_stripe_child(
selector: &str,
width: f64,
edge: Option<&str>,
bg: Option<Rgba>,
) -> Vec<RuleHit> {
let Some(edge) = edge else {
return Vec::new();
};
if !(width >= 2.0 && width <= 12.0) {
return Vec::new();
}
let Some(bg) = bg else {
return Vec::new();
};
if bg.alpha_or_one() <= 0.1 {
return Vec::new();
}
let spread = js::math_max3(bg.r, bg.g, bg.b) - js::math_min3(bg.r, bg.g, bg.b);
if spread < 30.0 {
return Vec::new();
}
vec![RuleHit::new(
"side-tab",
format!(
"{selector} — {}px stripe child ({edge})",
number_to_string(math_round(width))
),
)]
}

re!(GRADIENT_CI, ci("gradient"));

re!(
Expand Down
176 changes: 152 additions & 24 deletions crates/detect/src/regex_matchers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,36 +146,65 @@ where
}
}

/// Opening-tag span that contains `index`, if any. `end` is the `>` byte.
fn markup_tag_span(line: &str, index: usize) -> Option<(usize, usize)> {
let mut i = 0usize;
while i < line.len() {
let Some(rel) = line[i..].find('<') else {
return None;
};
let tag_start = i + rel;
let after = &line[tag_start + 1..];
if !after.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
i = tag_start + 1;
continue;
}
let mut tag_end: Option<usize> = None;
scan_js(line, tag_start + 1, |ch, j, _p, _n, depth| {
if ch == '>' && depth.brace == 0 {
tag_end = Some(j);
return true;
}
false
});
let Some(end) = tag_end else {
return None;
};
if index >= tag_start && index <= end {
return Some((tag_start, end));
}
i = end + 1;
}
None
}

/// JS: detect-text.mjs#containingMarkupTag (only its `text` is read).
fn containing_markup_tag(line: &str) -> impl Fn(usize) -> String + '_ {
move |index: usize| {
let mut i = 0usize;
while i < line.len() {
let Some(rel) = line[i..].find('<') else { break };
let tag_start = i + rel;
let after = &line[tag_start + 1..];
if !after.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
i = tag_start + 1;
continue;
}
let mut tag_end: Option<usize> = None;
scan_js(line, tag_start + 1, |ch, j, _p, _n, depth| {
if ch == '>' && depth.brace == 0 {
tag_end = Some(j);
return true;
}
false
});
let Some(end) = tag_end else { break };
if index >= tag_start && index <= end {
return line[tag_start..end + 1].to_string();
}
i = end + 1;
}
line.to_string()
markup_tag_span(line, index)
.map(|(start, end)| line[start..end + 1].to_string())
.unwrap_or_else(|| line.to_string())
}
}

fn is_self_closing_tag(tag: &str) -> bool {
tag.trim_end_matches('>').trim_end().ends_with('/')
}

/// Text path cannot see the DOM. When this line holds a whole tag, require
/// it empty or self-closing. A class list with no `<` is a split JSX tag,
/// so emptiness is unknown and the other gates still apply.
fn stripe_child_markup_empty(line: &str, index: usize) -> bool {
let Some((start, end)) = markup_tag_span(line, index) else {
return true;
};
Comment thread
abdulwahabone marked this conversation as resolved.
if is_self_closing_tag(&line[start..end + 1]) {
return true;
}
let rest = line.get(end + 1..).unwrap_or("").trim_start();
rest.starts_with("</")
}

struct TernarySplit {
common: String,
consequent: String,
Expand Down Expand Up @@ -494,6 +523,43 @@ re!(
SIDE_TAB_JS_RE,
format!("border(?:Left|Right){WS}*[:=]{WS}*[\"'`]({D}+)px{WS}+solid")
);
re!(
SIDE_TAB_STRIPE_CHILD_TW_RE,
r"w-(?:0\.5|1(?:\.5)?|2(?:\.5)?|3|\[(?:[2-9]|1[0-2])px\])"
);
re!(STRIPE_CHILD_HEIGHT_TOKEN_RE, r"h-(?:px\b|[0-9]|\[)");
re!(
STRIPE_CHILD_ARIA_RE,
r"(?i)aria-(?:current|selected)"
);
re!(STRIPE_CHILD_ROUNDED_FULL_RE, format!("{B}rounded-full{B}"));
re!(
STRIPE_CHILD_CUE_RE,
format!("{B}(?:shrink-0|rounded-[lres](?:-{W}+)?|left-0|right-0|inset-y-0){B}")
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
);
Comment thread
cursor[bot] marked this conversation as resolved.

/// Hyphen-safe class-token boundary: the byte before `index` must not be `-`
/// or an ASCII word character (mirrors JS `(?<![\w-])`; the `regex` crate has
/// no lookbehind).
fn hyphen_safe_prefix(text: &str, index: usize) -> bool {
match text.as_bytes().get(index.wrapping_sub(1)) {
Some(b) if index > 0 => !b.is_ascii_alphanumeric() && *b != b'-',
_ => true,
}
}

fn hyphen_safe_suffix(text: &str, end: usize) -> bool {
!matches!(
text.as_bytes().get(end),
Some(b) if b.is_ascii_alphanumeric() || *b == b'-' || *b == b'.' || *b == b'/'
)
}

fn scope_has_fixed_height(scope: &str) -> bool {
STRIPE_CHILD_HEIGHT_TOKEN_RE.find_iter(scope).any(|m| {
hyphen_safe_prefix(scope, m.start())
})
}
re!(BORDER_ACCENT_TW_RE, format!("{B}border-[tb]-({D}+){B}"));
re!(
BORDER_ACCENT_CSS_RE,
Expand Down Expand Up @@ -864,6 +930,29 @@ pub static REGEX_MATCHERS: Lazy<Vec<Matcher>> = Lazy::new(|| {
test: |m, _| num(m.g(1)) >= 3.0,
fmt: |m, _| m.whole().to_string(),
},
Matcher {
id: "side-tab",
find_all: |l| all(&SIDE_TAB_STRIPE_CHILD_TW_RE, l),
test: |m, line| {
if !hyphen_safe_prefix(line, m.index)
|| !hyphen_safe_suffix(line, m.index + m.whole().len())
{
return false;
}
let scope = containing_markup_tag(line)(m.index);
find_solid_chromatic_bg(&scope).is_some()
&& stripe_child_markup_empty(line, m.index)
&& STRIPE_CHILD_CUE_RE.is_match(&scope)
&& !scope_has_fixed_height(&scope)
&& !STRIPE_CHILD_ROUNDED_FULL_RE.is_match(&scope)
&& !STRIPE_CHILD_ARIA_RE.is_match(&scope)
Comment thread
abdulwahabone marked this conversation as resolved.
},
fmt: |m, line| {
let scope = containing_markup_tag(line)(m.index);
let bg = find_solid_chromatic_bg(&scope).unwrap();
format!("{} + {bg} stripe child", m.whole())
},
},
Matcher {
id: "border-accent-on-rounded",
find_all: |l| all(&BORDER_ACCENT_TW_RE, l),
Expand Down Expand Up @@ -1433,6 +1522,45 @@ mod tests {
);
}

#[test]
fn stripe_child_tailwind() {
let s = |line: &str| run("side-tab", line);
assert_eq!(
s(r#"<div className="w-1 shrink-0 rounded-l-lg bg-amber-500" />"#),
vec!["w-1 + bg-amber-500 stripe child"]
);
assert_eq!(
s(r#"<div class="w-[4px] bg-blue-500 shrink-0"></div>"#),
vec!["w-[4px] + bg-blue-500 stripe child"]
);
assert_eq!(
s(r#"<span className="w-0.5 bg-rose-500 shrink-0" />"#),
vec!["w-0.5 + bg-rose-500 stripe child"]
);
assert_eq!(
s(r#"<div className="w-1 min-h-0 bg-amber-500 shrink-0" />"#),
vec!["w-1 + bg-amber-500 stripe child"]
);
assert!(s(r#"<div className="w-2 h-2 rounded-full bg-green-500" />"#).is_empty());
assert!(s(
r#"<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded bg-amber-500" /><span className="text-slate-400">Vital few</span></div>"#
)
.is_empty());
assert!(s(r#"<div className="w-1 bg-amber-500/10" />"#).is_empty());
assert!(s(r#"<a className="w-1 bg-amber-500" aria-current="page"></a>"#).is_empty());
assert!(s(
r#"<div className="w-1 shrink-0"><span className="bg-amber-500" /></div>"#
)
.is_empty());
assert!(s(r#"<div className="w-1 bg-amber-500">|</div>"#).is_empty());
assert!(s(r#"<div className="w-1 bg-amber-500" />"#).is_empty());
assert_eq!(
s(r#" className="w-1 shrink-0 rounded-l-lg bg-amber-500""#),
vec!["w-1 + bg-amber-500 stripe child"]
);
assert!(s(r#"<div className="w-1 shrink-0 bg-amber-500">"#).is_empty());
}

#[test]
fn matchers() {
assert_eq!(
Expand Down
Loading