Compare commits

...
Author SHA1 Message Date
Abdul WahabandCursor 3950a8221d Fix: keep multiline JSX stripe-child matches (#394)
A class-only line has no opening tag, so emptiness is unknown; still require a self-closing or empty tag when the tag is on this line.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 10:05:56 +05:00
Abdul WahabandCursor fc5dcefce2 Fix: tighten stripe-child text and static cascade gates (#394)
Keep flex/align out of the frozen expandStaticDeclaration map, require empty tagged stripe cues on the text path, and honor inset order plus rem width.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 09:58:28 +05:00
Abdul WahabandCursor 5c67bf725d Fix: detect side-tab stripes drawn as child elements (#394)
Empty narrow chromatic children (Tailwind w-1 bg-*, flex-row rails, absolute inset) now flag as side-tab across the text, static HTML, and browser engines.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 09:43:54 +05:00
24 changed files with 867 additions and 88 deletions
+1
View File
@@ -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)));
+92 -2
View File
@@ -20,8 +20,9 @@ use crate::checks::measures::{
};
use crate::checks::rules::{
check_borders, check_colors, check_glow, check_hero_eyebrow, check_icon_tile,
check_italic_serif, check_motion, is_emoji_only_text, BorderOpts, ColorOpts, GlowOpts,
HeroEyebrowOpts, IconTileOpts, ItalicSerifOpts, MotionOpts, RuleHit, Sides, HEADING_TAGS,
check_italic_serif, check_motion, check_stripe_child, 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,
@@ -367,6 +368,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();
}
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 {
@@ -1378,6 +1430,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 colors_low_contrast_on_resolved_surface_and_pseudo_surface() {
let (mut d, body) = page();
+33
View File
@@ -88,6 +88,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!(
+152 -24
View File
@@ -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;
};
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,
@@ -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}")
);
/// 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,
@@ -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)
},
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),
@@ -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!(
+117 -4
View File
@@ -9,9 +9,10 @@ use crate::background::{
a_ge, a_gt, read_own_background_color, resolve_background, resolve_background_info,
resolve_border_radius_px, resolve_gradient_stops, sv, sv_opt, CustomPropMap,
};
use crate::cascade::StyleValues;
use crate::cascade::{expand_static_box_values, split_css_tokens, StyleValues};
use crate::dom::{StaticDocument, StaticElement};
use crate::quality::{collapse_ws, pf0, resolve_font_size_px};
use impeccable_core::checks::css_scan::css_length_to_px;
use impeccable_core::checks::measures::{
self, border_colors_from_style, border_widths_from_style, check_gpt_thin_border_wide_shadow,
check_oversized_h1, check_radial_spotlight, positioned_style_implies_escape, resolve_length_px,
@@ -20,9 +21,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_stripe_child, 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,
@@ -460,6 +461,118 @@ pub fn check_element_borders(
)
}
const STRIPE_CHILD_SKIP: &str = "nav, blockquote, pre, table, button, a, select, progress, meter, [role=\"progressbar\"], [role=\"slider\"], [role=\"scrollbar\"], [role=\"separator\"], [role=\"tablist\"]";
fn static_edge_hugs(value: &str) -> bool {
let n = parse_float(value);
n.is_finite() && n.abs() <= 2.0
}
fn static_resolved_inset(style: &StyleValues) -> [String; 4] {
let mut out = [
sv(style, "top").to_string(),
sv(style, "right").to_string(),
sv(style, "bottom").to_string(),
sv(style, "left").to_string(),
];
let inset = sv(style, "inset");
if !inset.is_empty() {
let expanded = expand_static_box_values(&split_css_tokens(inset));
for (i, val) in expanded.into_iter().enumerate() {
if out[i].is_empty() || out[i] == "auto" {
out[i] = val;
}
}
}
out
}
/// JS: checks.mjs#checkElementStripeChild(el, style)
pub fn check_element_stripe_child(el: &StaticElement<'_>, style: &StyleValues) -> Vec<RuleHit> {
let tag = el.tag_lower();
if tag != "div" && tag != "span" {
return Vec::new();
}
let Some(host) = el.parent_element() else {
return Vec::new();
};
if host.tag_lower() == "body" || host.tag_lower() == "html" {
return Vec::new();
}
if !el.children().is_empty() {
return Vec::new();
}
if !collapsed_text_content(el).is_empty() {
return Vec::new();
}
if el.closest(STRIPE_CHILD_SKIP).is_some() {
return Vec::new();
}
if is_tab_context_element(el) || is_status_context_element(el) {
return Vec::new();
}
let width = css_length_to_px(sv(style, "width")).unwrap_or_else(|| pf0(sv(style, "width")));
let position = js::to_lower_case(sv(style, "position"));
let host_style = host.style();
let edge = if position == "absolute" || position == "fixed" {
let height_raw = sv(style, "height");
let inset = static_resolved_inset(style);
let height_stretches =
height_raw == "100%" || (static_edge_hugs(&inset[0]) && static_edge_hugs(&inset[2]));
if !height_stretches {
return Vec::new();
}
if static_edge_hugs(&inset[3]) {
Some("left")
} else if static_edge_hugs(&inset[1]) {
Some("right")
} else {
None
}
} else {
let pdisplay = sv(host_style, "display");
if !pdisplay.contains("flex") {
return Vec::new();
}
let pdir = sv(host_style, "flexDirection");
if pdir.starts_with("column") {
return Vec::new();
}
let align_self = sv(style, "alignSelf");
let effective_align = if !align_self.is_empty() && align_self != "auto" {
align_self
} else {
sv(host_style, "alignItems")
};
let is_stretch = effective_align.is_empty()
|| effective_align == "stretch"
|| effective_align == "normal";
let height_raw = sv(style, "height");
let height_stretches =
height_raw == "100%" || ((height_raw.is_empty() || height_raw == "auto") && is_stretch);
if !height_stretches {
return Vec::new();
}
let siblings = host.children();
if siblings.len() < 2 {
return Vec::new();
}
let reverse = pdir.contains("reverse");
if siblings.first() == Some(el) {
Some(if reverse { "right" } else { "left" })
} else if siblings.last() == Some(el) {
Some(if reverse { "left" } else { "right" })
} else {
None
}
};
let bg_raw = sv(style, "backgroundColor");
let bg = parse_rgb(Some(&bg_raw)).or_else(|| parse_any_color(Some(&bg_raw)));
check_stripe_child(&class_selector(el), width, edge, bg)
}
/// JS: checks.mjs#checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule)
pub fn check_element_colors(
el: &StaticElement<'_>,
+3
View File
@@ -184,6 +184,9 @@ pub const STATIC_PROP_MAP: &[(&str, &str)] = &[
("left", "left"),
("inset", "inset"),
("display", "display"),
("flex-direction", "flexDirection"),
("align-items", "alignItems"),
("align-self", "alignSelf"),
("overflow", "overflow"),
("overflow-x", "overflowX"),
("overflow-y", "overflowY"),
+33 -2
View File
@@ -65,7 +65,8 @@
//! parse in the same process).
use super::csstree::{self, Important, Node};
use super::shorthand::expand_static_declaration;
use super::shorthand::{expand_static_box_values, expand_static_declaration, Expanded};
use super::values::split_css_tokens;
use impeccable_core::js;
use indexmap::IndexMap;
use once_cell::sync::Lazy;
@@ -177,6 +178,32 @@ impl<K: Hash + Eq> SpecifiedStore<K> {
}
}
/// Layout properties the stripe-child static adapter needs that are not in
/// the frozen `expandStaticDeclaration` allowlist. Applied here so the
/// recorded vectors stay byte-equal.
fn extra_specified_expansions(prop: &str, value: &str) -> Vec<Expanded> {
let p = js::to_lower_case(prop);
let v = js::trim(value);
if v.is_empty() {
return Vec::new();
}
match p.as_str() {
"flex-direction" => vec![("flexDirection".into(), v.to_string())],
"align-items" => vec![("alignItems".into(), v.to_string())],
"align-self" => vec![("alignSelf".into(), v.to_string())],
"inset" => {
let vals = expand_static_box_values(&split_css_tokens(v));
vec![
("top".into(), vals[0].clone()),
("right".into(), vals[1].clone()),
("bottom".into(), vals[2].clone()),
("left".into(), vals[3].clone()),
]
}
_ => Vec::new(),
}
}
/// JS: css-cascade.mjs#applyStaticDeclaration(specified, node, prop, value, meta)
pub fn apply_static_declaration<K: Hash + Eq>(
specified: &mut SpecifiedStore<K>,
@@ -186,7 +213,11 @@ pub fn apply_static_declaration<K: Hash + Eq>(
meta: &DeclMeta,
) {
let map = specified.map.entry(node).or_default();
for (expanded_prop, expanded_value) in expand_static_declaration(prop, value) {
let extra = extra_specified_expansions(prop, value);
for (expanded_prop, expanded_value) in expand_static_declaration(prop, value)
.into_iter()
.chain(extra)
{
let existing = map.get(&expanded_prop).map(|d| &d.meta);
if compare_static_priority(existing, meta) {
let next = SpecifiedDecl {
+5 -2
View File
@@ -14,8 +14,9 @@ use crate::adapters::{
check_element_colors, check_element_glow, check_element_gpt_border_shadow,
check_element_hero_eyebrow, check_element_hover_contrast, check_element_icon_tile,
check_element_italic_serif, check_element_motion, check_element_oversized_h1,
check_element_radial_spotlight, check_kicker_above_heading_from_doc,
check_numbered_section_labels_from_doc, scoped_ignore_active,
check_element_radial_spotlight, check_element_stripe_child,
check_kicker_above_heading_from_doc, check_numbered_section_labels_from_doc,
scoped_ignore_active,
};
use crate::background::{resolve_background, resolve_border_radius_px, sv};
use crate::cascade::{build_static_style_map, collect_static_css_text};
@@ -109,6 +110,7 @@ const STATIC_ELEMENT_RULES: &[(&str, &str)] = &[
("dark-glow", "*"),
("motion-rules", "*"),
("icon-tile-stack", "h1,h2,h3,h4,h5,h6"),
("stripe-child", "div,span"),
("italic-serif-display", "h1,h2"),
("hero-eyebrow-chip", "h1"),
("broken-image", "img"),
@@ -134,6 +136,7 @@ fn run_rule(rule_id: &str, el: &StaticElement<'_>, tag: &str) -> Vec<RuleHit> {
}
"motion-rules" => check_element_motion(tag, style),
"icon-tile-stack" => check_element_icon_tile(el, tag),
"stripe-child" => check_element_stripe_child(el, style),
"italic-serif-display" => check_element_italic_serif(el, style, tag),
"hero-eyebrow-chip" => check_element_hero_eyebrow(el, style, tag),
"broken-image" => check_element_broken_image(el),
+167
View File
@@ -0,0 +1,167 @@
use impeccable_html::{detect_html_source, DetectHtmlOptions};
use std::path::Path;
fn side_tab_snippets(html: &str) -> Vec<String> {
detect_html_source(
html,
Path::new("/app/stripe.html"),
&DetectHtmlOptions::default(),
)
.into_iter()
.filter(|f| f.antipattern == "side-tab")
.map(|f| f.snippet)
.collect()
}
#[test]
fn flex_row_first_child_flags() {
let html = r#"<!DOCTYPE html><html><head><style>
.card { display: flex; flex-direction: row; width: 320px; height: 100px; }
.stripe { width: 4px; background: #f59e0b; }
.body { flex: 1; }
</style></head><body>
<div class="card"><div class="stripe"></div><div class="body">Content</div></div>
</body></html>"#;
let hits = side_tab_snippets(html);
assert_eq!(hits.len(), 1);
assert!(hits[0].contains("stripe child (left)"));
}
#[test]
fn absolute_left_inset_flags() {
let html = r#"<!DOCTYPE html><html><head><style>
.card { position: relative; width: 320px; height: 100px; }
.stripe { position: absolute; inset: 0 auto 0 0; width: 4px; background: #3b82f6; }
</style></head><body>
<div class="card"><div class="stripe"></div></div>
</body></html>"#;
let hits = side_tab_snippets(html);
assert_eq!(hits.len(), 1);
assert!(hits[0].contains("stripe child (left)"));
}
#[test]
fn absolute_top_bottom_flags() {
let html = r#"<!DOCTYPE html><html><head><style>
.card { position: relative; width: 320px; height: 100px; }
.stripe { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: #3b82f6; }
</style></head><body>
<div class="card"><div class="stripe"></div></div>
</body></html>"#;
let hits = side_tab_snippets(html);
assert_eq!(hits.len(), 1);
assert!(hits[0].contains("stripe child (left)"));
}
#[test]
fn flex_column_does_not_flag() {
let html = r#"<!DOCTYPE html><html><head><style>
.card { display: flex; flex-direction: column; width: 320px; height: 100px; }
.stripe { width: 4px; background: #f59e0b; }
</style></head><body>
<div class="card"><div class="stripe"></div><div>Body</div></div>
</body></html>"#;
assert!(side_tab_snippets(html).is_empty());
}
#[test]
fn align_items_center_does_not_flag() {
let html = r#"<!DOCTYPE html><html><head><style>
.card { display: flex; align-items: center; width: 320px; height: 100px; }
.stripe { width: 4px; background: #f59e0b; }
</style></head><body>
<div class="card"><div class="stripe"></div><div>Body</div></div>
</body></html>"#;
assert!(side_tab_snippets(html).is_empty());
}
#[test]
fn align_self_flex_start_does_not_flag() {
let html = r#"<!DOCTYPE html><html><head><style>
.card { display: flex; width: 320px; height: 100px; }
.stripe { width: 4px; align-self: flex-start; background: #f59e0b; }
</style></head><body>
<div class="card"><div class="stripe"></div><div>Body</div></div>
</body></html>"#;
assert!(side_tab_snippets(html).is_empty());
}
#[test]
fn neutral_and_contentful_and_wide_do_not_flag() {
let neutral = r#"<!DOCTYPE html><html><head><style>
.card { display: flex; width: 320px; height: 100px; }
.stripe { width: 4px; background: #e5e5e5; }
</style></head><body>
<div class="card"><div class="stripe"></div><div>Body</div></div>
</body></html>"#;
assert!(side_tab_snippets(neutral).is_empty());
let text = r#"<!DOCTYPE html><html><head><style>
.card { display: flex; width: 320px; height: 100px; }
.stripe { width: 4px; background: #f59e0b; }
</style></head><body>
<div class="card"><div class="stripe">!</div><div>Body</div></div>
</body></html>"#;
assert!(side_tab_snippets(text).is_empty());
let wide = r#"<!DOCTYPE html><html><head><style>
.card { display: flex; width: 320px; height: 100px; }
.stripe { width: 40px; background: #f59e0b; }
</style></head><body>
<div class="card"><div class="stripe"></div><div>Body</div></div>
</body></html>"#;
assert!(side_tab_snippets(wide).is_empty());
}
#[test]
fn rem_width_flags() {
let html = r#"<!DOCTYPE html><html><head><style>
.card { display: flex; width: 320px; height: 100px; }
.stripe { width: 0.25rem; background: #f59e0b; }
</style></head><body>
<div class="card"><div class="stripe"></div><div>Body</div></div>
</body></html>"#;
let hits = side_tab_snippets(html);
assert_eq!(hits.len(), 1);
assert!(hits[0].contains("stripe child (left)"));
}
#[test]
fn height_full_with_align_center_flags() {
let html = r#"<!DOCTYPE html><html><head><style>
.card { display: flex; align-items: center; width: 320px; height: 100px; }
.stripe { width: 4px; height: 100%; background: #f59e0b; }
</style></head><body>
<div class="card"><div class="stripe"></div><div>Body</div></div>
</body></html>"#;
let hits = side_tab_snippets(html);
assert_eq!(hits.len(), 1);
assert!(hits[0].contains("stripe child (left)"));
}
#[test]
fn inset_after_left_longhand_flags() {
let html = r#"<!DOCTYPE html><html><head><style>
.card { position: relative; width: 320px; height: 100px; }
.stripe { position: absolute; left: 10px; inset: 0 auto 0 0; width: 4px; background: #3b82f6; }
</style></head><body>
<div class="card"><div class="stripe"></div></div>
</body></html>"#;
let hits = side_tab_snippets(html);
assert_eq!(hits.len(), 1);
assert!(hits[0].contains("stripe child (left)"));
}
#[test]
fn row_reverse_first_child_is_right() {
let html = r#"<!DOCTYPE html><html><head><style>
.card { display: flex; flex-direction: row-reverse; width: 320px; height: 100px; }
.stripe { width: 4px; background: #f59e0b; }
.body { flex: 1; }
</style></head><body>
<div class="card"><div class="stripe"></div><div class="body">Content</div></div>
</body></html>"#;
let hits = side_tab_snippets(html);
assert_eq!(hits.len(), 1);
assert!(hits[0].contains("stripe child (right)"));
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -332,7 +332,7 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
#### Static and regex engines (only what affects the contract)
- `detectHtml`: reads file, imports `htmlparser2`, `css-select`, `css-tree`, `domutils`; on import failure prints once to stderr `impeccable detect: DEGRADED - HTML parser modules unavailable (htmlparser2, css-select, css-tree, domutils).\nFalling back to regex matching. Custom properties, selector matching and computed contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n` and falls back to `detectText`. Inlines `<link rel=stylesheet href>` that are local (not `/^(https?:)?\/\//i`), query/hash stripped. Runs element rules, design-system rules (`checkSourceDesignSystem` + `collectStaticDesignSystemFindings`, merged), then page rules only when `isFullPage(html)` (`/<!doctype\s|<html[\s>]|<head[\s>]/i` after stripping comments), plus text-content analyzers; ends with inline-ignore filtering.
- `detectText`: regex line matchers (ids: side-tab, border-accent-on-rounded, overused-font, gradient-text, ai-color-palette, gray-on-color, bounce-easing, layout-transition, broken-image), inset-stripe/pseudo-stripe CSS scans, `codex-grid-background`, `<style>` blocks (Astro/Vue/Svelte), CSS-in-JS templates, design-system source checks; dedupe (same antipattern+snippet within 2 lines); page analyzers only when `isFullPage` and ext ∈ `{'.html','.htm','.astro','.vue','.svelte'}` or no ext (`<stdin>`): flat-type-hierarchy, monotonous-spacing, em-dash-overuse, marketing-buzzword, aphoristic-cadence, dark-glow (+ radial-halo, marquee); inline ignores last.
- `detectText`: regex line matchers (ids: side-tab including Tailwind stripe-child empty `w-*` + chromatic `bg-*` plus a `shrink-0` / edge-round cue, border-accent-on-rounded, overused-font, gradient-text, ai-color-palette, gray-on-color, bounce-easing, layout-transition, broken-image), inset-stripe/pseudo-stripe CSS scans, `codex-grid-background`, `<style>` blocks (Astro/Vue/Svelte), CSS-in-JS templates, design-system source checks; dedupe (same antipattern+snippet within 2 lines); page analyzers only when `isFullPage` and ext ∈ `{'.html','.htm','.astro','.vue','.svelte'}` or no ext (`<stdin>`): flat-type-hierarchy, monotonous-spacing, em-dash-overuse, marketing-buzzword, aphoristic-cadence, dark-glow (+ radial-halo, marquee); inline ignores last.
#### Profiler (`cli/engine/profile/profiler.mjs`)
+121
View File
@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Stripe-Child Side-Tab Fixture</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 960px; margin: 0 auto; }
.col h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
.case { margin-bottom: 24px; padding: 16px; border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
/* FLAG: flex-row first child stripe */
.card-flex-left { display: flex; flex-direction: row; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; }
.stripe-left { width: 4px; background: #f59e0b; flex-shrink: 0; }
.card-flex-left .body { flex: 1; padding: 16px; }
/* FLAG: flex-row last child stripe */
.card-flex-right { display: flex; flex-direction: row; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; }
.card-flex-right .body { flex: 1; padding: 16px; }
.stripe-right { width: 4px; background: #3b82f6; flex-shrink: 0; }
/* FLAG: absolute left stripe */
.card-abs { position: relative; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
.stripe-abs { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: #f59e0b; }
/* FLAG: absolute inset shorthand */
.card-inset { position: relative; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
.stripe-inset { position: absolute; inset: 0 auto 0 0; width: 4px; background: #3b82f6; }
/* PASS: neutral gray stripe */
.card-neutral { display: flex; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
.stripe-neutral { width: 4px; background: #e5e5e5; }
/* PASS: black / low-spread fill */
.card-black { display: flex; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
.stripe-black { width: 4px; background: black; }
/* PASS: align-items center (short child) */
.card-center { display: flex; align-items: center; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
.stripe-center { width: 4px; background: #f59e0b; }
/* PASS: flex-direction column */
.card-column { display: flex; flex-direction: column; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
.stripe-column { width: 4px; background: #f59e0b; }
/* PASS: active/selected host context */
.card-active { display: flex; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
.card-active.is-active .stripe-active { width: 4px; background: #f59e0b; }
/* PASS: contentful narrow child */
.card-text { display: flex; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
.stripe-text { width: 4px; background: #f59e0b; }
/* PASS: progressbar role */
.card-progress { display: flex; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
.stripe-progress { width: 4px; background: #f59e0b; }
/* PASS: wide first child */
.card-wide { display: flex; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
.stripe-wide { width: 40px; background: #f59e0b; }
</style>
</head>
<body>
<div class="grid">
<div class="col">
<h2>Should flag</h2>
<div class="case">
<h3>Flex row first-child stripe</h3>
<div class="card-flex-left"><div class="stripe-left"></div><div class="body">Card body</div></div>
</div>
<div class="case">
<h3>Flex row last-child stripe</h3>
<div class="card-flex-right"><div class="body">Card body</div><div class="stripe-right"></div></div>
</div>
<div class="case">
<h3>Absolute left stripe</h3>
<div class="card-abs"><div class="stripe-abs"></div><div class="body">Card body</div></div>
</div>
<div class="case">
<h3>Absolute inset stripe</h3>
<div class="card-inset"><div class="stripe-inset"></div><div class="body">Card body</div></div>
</div>
</div>
<div class="col">
<h2>Should pass</h2>
<div class="case">
<h3>Neutral gray stripe</h3>
<div class="card-neutral"><div class="stripe-neutral"></div><div class="body">Card body</div></div>
</div>
<div class="case">
<h3>Black stripe fill</h3>
<div class="card-black"><div class="stripe-black"></div><div class="body">Card body</div></div>
</div>
<div class="case">
<h3>Align items center short child</h3>
<div class="card-center"><div class="stripe-center"></div><div class="body">Card body</div></div>
</div>
<div class="case">
<h3>Flex column layout</h3>
<div class="card-column"><div class="stripe-column"></div><div class="body">Card body</div></div>
</div>
<div class="case">
<h3>Active selected host</h3>
<div class="card-active is-active"><div class="stripe-active"></div><div>Card body</div></div>
</div>
<div class="case">
<h3>Contentful narrow child</h3>
<div class="card-text"><div class="stripe-text">|</div><div class="body">Card body</div></div>
</div>
<div class="case">
<h3>Progressbar context</h3>
<div class="card-progress" role="progressbar"><div class="stripe-progress"></div><div class="body">50%</div></div>
</div>
<div class="case">
<h3>Wide forty pixel child</h3>
<div class="card-wide"><div class="stripe-wide"></div><div class="body">Card body</div></div>
</div>
</div>
</div>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
// Dedicated stripe-child side-tab fixture (Tailwind / JSX text path)
export function StripeChildCard() {
return (
<div className="flex rounded-lg border">
<div className="w-1 shrink-0 rounded-l-lg bg-amber-500" />
<div className="p-4">Card content</div>
</div>
);
}
export function StripeChildBracketWidth() {
return <div className="w-[4px] bg-blue-500 shrink-0" />;
}
export function StripeChildHalf() {
return <span className="w-0.5 bg-rose-500 shrink-0" />;
}
export function StripeChildMinHeightOk() {
return <div className="w-1 min-h-0 bg-amber-500 shrink-0" />;
}
// PASS: dot indicator, not a stripe
export function DotIndicator() {
return <div className="w-2 h-2 rounded-full bg-green-500" />;
}
// PASS: small square with sibling text (gray-on-color sibling line)
export function VitalFewLegend() {
return (
<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>
);
}
// PASS: chart bar with explicit height
export function ChartBar() {
return <div className="w-3 h-24 bg-blue-500" />;
}
// PASS: opacity tint
export function TintStripe() {
return <div className="w-1 bg-amber-500/10 shrink-0" />;
}
// PASS: aria-current on stripe tag
export function CurrentNavStripe() {
return <a className="w-1 bg-amber-500" aria-current="page" />;
}
// PASS: width and chromatic bg on sibling tags
export function SplitSiblingClasses() {
return (
<div className="w-1 shrink-0">
<span className="bg-amber-500" />
</div>
);
}
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": "432 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
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
{
"stdout": "[\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/stripe-child.jsx\",\n \"line\": 6,\n \"snippet\": \"w-1 + bg-amber-500 stripe child\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/stripe-child.jsx\",\n \"line\": 13,\n \"snippet\": \"w-[4px] + bg-blue-500 stripe child\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/stripe-child.jsx\",\n \"line\": 17,\n \"snippet\": \"w-0.5 + bg-rose-500 stripe child\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/stripe-child.jsx\",\n \"line\": 21,\n \"snippet\": \"w-1 + bg-amber-500 stripe child\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
"files": {}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/stripe-child.jsx\n line 6: [side-tab] w-1 + bg-amber-500 stripe child\n → Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\n line 13: [side-tab] w-[4px] + bg-blue-500 stripe child\n → Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\n line 17: [side-tab] w-0.5 + bg-rose-500 stripe child\n → Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\n line 21: [side-tab] w-1 + bg-amber-500 stripe child\n → Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long