Compare commits

..
Author SHA1 Message Date
Paul Bakaus 8b0cc87023 Fix stripe-child edge and token matching
AI-assisted changes requested by pbakaus. Preserve winning auto inset longhands and require complete Tailwind cue tokens. Added regressions that fail before the fixes; focused tests, cargo test --workspace, release build, and the full rebuilt-engine Bun/Node suite pass.
2026-09-14 18:36:05 -07:00
Paul Bakaus afc0596903 Merge main and preserve stripe-child coverage
AI-assisted conflict resolution performed by Codex at maintainer pbakaus request. Preserve both stripe-child and placeholder checks/tests; rebuild the browser bundle. Reviewed aggregate oracle deltas: only main placeholder cases added to the prior PR results, no findings removed. Rust workspace, source-first build, full Bun/Node/oracle/plugin suite, and real Chrome stripe fixture passed.
2026-09-14 18:22:10 -07:00
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
26 changed files with 830 additions and 59 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)));
+91 -1
View File
@@ -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,
};
@@ -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();
}
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 {
@@ -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();
+33
View File
@@ -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!(
+167 -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,32 @@ 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.find_iter(&scope).any(|cue| {
hyphen_safe_prefix(&scope, cue.start())
&& hyphen_safe_suffix(&scope, cue.end())
})
&& !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 +1525,57 @@ mod tests {
);
}
#[test]
fn stripe_child_cues_require_complete_class_tokens() {
for cue in ["left-0.5", "right-0.5", "inset-y-0.5", "-left-0", "left-0/2", "shrink-0.5"] {
let source = format!(r#"<div className="w-1 {cue} bg-amber-500" />"#);
assert!(run("side-tab", &source).is_empty(), "{cue}");
}
for cue in ["left-0", "right-0", "inset-y-0", "shrink-0", "rounded-l-lg"] {
let source = format!(r#"<div className="w-1 {cue} bg-amber-500" />"#);
assert_eq!(run("side-tab", &source).len(), 1, "{cue}");
}
}
#[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!(
+98 -1
View File
@@ -12,6 +12,7 @@ use crate::background::{
use crate::cascade::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,7 +21,8 @@ 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,
check_placeholder_colors, is_emoji_only_text, is_heading_tag, resolve_hero_heading_size_px,
check_placeholder_colors, 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,
};
@@ -460,6 +462,101 @@ 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
}
/// 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");
// The cascade already expands inset; a winning `auto` longhand
// must not be overwritten by the earlier shorthand.
let inset = ["top", "right", "bottom", "left"].map(|prop| sv(style, prop));
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),
+178
View File
@@ -0,0 +1,178 @@
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 winning_auto_longhand_is_not_replaced_by_inset() {
let html = r#"<html><body><div style="position:relative;width:320px;height:100px">
<div class="stripe" style="position:absolute;inset:0;left:auto;width:4px;background:#3b82f6"></div>
</div></body></html>"#;
let hits = side_tab_snippets(html);
assert_eq!(hits.len(), 1);
assert!(hits[0].contains("stripe child (right)"), "{hits:?}");
assert!(side_tab_snippets(&html.replace("left:auto", "left:auto;right:auto")).is_empty());
}
#[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`)
+1 -1
View File
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `{{scripts_path}}/impeccable concept-seed --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. <!-- rule:skill-concept-procedure --> <!-- rule:skill-verdict-and-donation -->
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. <!-- rule:skill-pick-card-one-only -->
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. <!-- rule:skill-canon-standing-exit --> Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. <!-- rule:skill-assigned-plus-reroll --> Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `{{scripts_path}}/impeccable serve-question --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. A harness that cannot hold a blocking `--wait` (it backgrounds long-running commands, or reaps a backgrounded child) takes the structured tool without starting the page and discloses the substitution in the first reply, so the tab is never left unanswered. Never predict any other fallback: run the script, and only exit code 2 from starting it, or that wait check before starting, routes the decision to the structured tool; those are the fallback, never an error to retry. <!-- rule:skill-visual-decision-page -->
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. <!-- rule:skill-canon-standing-exit --> Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. <!-- rule:skill-assigned-plus-reroll --> Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `{{scripts_path}}/impeccable serve-question --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. <!-- rule:skill-visual-decision-page -->
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. <!-- rule:skill-decision-comps-full-fidelity --> <!-- rule:skill-salience-parity -->
+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": "419 anti-patterns found.\n17 advisory notes (not counted).\n",
"stderr": "436 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
-18
View File
@@ -68,24 +68,6 @@ describe('skill reference authoring contracts', () => {
assert.doesNotMatch(polish, /git status|git log/);
});
it('routes visual decision fallback through wait capability and start failure', () => {
const newWork = readFileSync(join(ROOT, 'skill/reference/new-work.md'), 'utf-8').replace(/\r\n?/g, '\n');
const visualDecisionPage = newWork.match(
/A harness that can leave a shell blocked[\s\S]*?<!-- rule:skill-visual-decision-page -->/,
)?.[0] ?? '';
assert.match(visualDecisionPage, /cannot hold a blocking `--wait`/);
assert.match(visualDecisionPage, /without starting the page/);
assert.match(visualDecisionPage, /structured tool/);
assert.match(visualDecisionPage, /first reply/);
assert.match(visualDecisionPage, /exit code 2 from starting it/);
assert.match(visualDecisionPage, /wait check before starting/);
assert.doesNotMatch(
visualDecisionPage,
/only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback/,
);
});
it('keeps touch-gesture verification in the adapt, audit, and harden references', () => {
const adapt = readFileSync(join(ROOT, 'skill/reference/adapt.md'), 'utf-8').replace(/\r\n?/g, '\n');
const audit = readFileSync(join(ROOT, 'skill/reference/audit.md'), 'utf-8').replace(/\r\n?/g, '\n');