mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 23:56:29 +03:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b0cc87023 | ||
|
|
afc0596903 | ||
|
|
3950a8221d | ||
|
|
fc5dcefce2 | ||
|
|
5c67bf725d |
@@ -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)));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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<'_>,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -140,26 +140,25 @@ fn locale_compare(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
fn check(io: &mut Io) -> R<()> {
|
||||
let (sys, _) = ctx(io);
|
||||
let root = sys.find_project_root();
|
||||
// A home-rooted check is the user-level equivalent of `update --global`.
|
||||
// Keep both verbs on the canonical provider paths so stale legacy paths
|
||||
// (for example ~/.pi/skills) cannot make only `check` report drift.
|
||||
let scope = if sys.is_home_dir(&root) { Some(Scope::User) } else { None };
|
||||
if sys.is_already_installed(&root, scope).is_none() {
|
||||
if sys.is_already_installed(&root, None).is_none() {
|
||||
out(io, "Impeccable is not installed in this project.");
|
||||
out(io, "Run `npx impeccable install` to install.");
|
||||
return Err(Flow::Exit(0));
|
||||
}
|
||||
let providers = sys.find_installed_providers(&root, scope);
|
||||
let providers = sys.find_installed_providers(&root, None);
|
||||
out(io, "Checking for updates...\n");
|
||||
let result = (|| -> Result<bool, String> {
|
||||
let bundle_dir = bundle::download_and_extract_bundle(&sys)?;
|
||||
let up_to_date = bundle::is_up_to_date(&sys, &root, &providers, &bundle_dir, scope, scope)?;
|
||||
// JS: agentScope 'user' for a home-rooted checkout (d2a9efb9), so
|
||||
// check() judges agent freshness against the user agent dirs.
|
||||
let agent_scope = if sys.is_home_dir(&root) { Some(Scope::User) } else { None };
|
||||
let up_to_date = bundle::is_up_to_date(&sys, &root, &providers, &bundle_dir, None, agent_scope)?;
|
||||
util::rm_rf(&bundle_dir);
|
||||
Ok(up_to_date)
|
||||
})();
|
||||
match result {
|
||||
Ok(true) => {
|
||||
let v = sys.get_skills_version(&root, scope);
|
||||
let v = sys.get_skills_version(&root, None);
|
||||
out(io, &format!("Skills are up to date{}.", version_suffix(&v)));
|
||||
}
|
||||
Ok(false) => {
|
||||
|
||||
@@ -460,40 +460,6 @@ fn check_accepts_current_copilot_user_agents_in_home_rooted_checkout() {
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_ignores_stale_legacy_pi_skills_when_the_user_install_is_current() {
|
||||
let root = temp_root("pi-check-home-scope");
|
||||
let home = format!("{root}/home");
|
||||
let tmpdir = format!("{root}/tmp");
|
||||
for d in [&home, &tmpdir] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
let bundle_root = create_fake_universal_bundle(&root, &[".pi"]);
|
||||
let env = base_env(&home, &tmpdir, &bundle_root);
|
||||
|
||||
let r = run_cli(
|
||||
&["install", "-y", "--scope=global", "--no-hooks", "--providers=pi"],
|
||||
&home,
|
||||
&env,
|
||||
);
|
||||
assert_eq!(r.code, 0, "{}\n{}", r.stdout, r.stderr);
|
||||
|
||||
let canonical = format!("{home}/.pi/agent/skills/impeccable");
|
||||
let legacy = format!("{home}/.pi/skills/impeccable");
|
||||
std::fs::create_dir_all(format!("{home}/.pi/skills")).unwrap();
|
||||
std::fs::create_dir_all(&legacy).unwrap();
|
||||
write(&format!("{legacy}/SKILL.md"), "---\nname: impeccable\nversion: 1.0.0-stale\n---\n");
|
||||
assert!(std::path::Path::new(&canonical).exists());
|
||||
|
||||
let update = run_cli(&["update", "--global", "-y", "--no-hooks"], &home, &env);
|
||||
assert!(update.stdout.contains("Skills are up to date"), "{}\n{}", update.stdout, update.stderr);
|
||||
|
||||
let check = run_cli(&["check"], &home, &env);
|
||||
assert!(check.stdout.contains("Skills are up to date"), "{}\n{}", check.stdout, check.stderr);
|
||||
assert!(!check.stdout.contains("Updates available"), "{}", check.stdout);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
// ─── inferred agent update scope (d2a9efb9) ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -383,7 +383,7 @@ retain their local-development trust behavior. See [bundle signing](BUNDLE-SIGNI
|
||||
Already installed (and not `--force`): `Impeccable skills are already installed (found in ${provider}/).`; compares tree hashes (`sha256` of file content with `\.(claude|cursor|...)\/skills\/` normalized to `.PROVIDER/skills/`); if differs → refresh + `Updated ${n} skill(s) to v${v}.`; missing hooks repaired; else `Skills are up to date (v${v}).` + `Run with --force to reinstall.`; offline → `Could not check for skill updates: ${msg}` + `Existing skills were left unchanged.`; ends `Done!` or the above; `exit 0`. Version read from `^version:\s*(.+)$` in installed `impeccable/SKILL.md`.
|
||||
- **update flags**: `-y|--yes`, `--force`, `--no-hooks`, scope flags as above (unknown → `Unknown update scope: ${v}. Use --project or --user.`). Resolves project vs user installs holding an `impeccable`/`*-impeccable`/`teach-impeccable` skill; none → `No impeccable skill folders found in this project or at the user level.` + `Run \`npx impeccable install\` to install first.`, exit 1; both → prompt `Update which? [project]/user: ` (non-TTY defaults project). Prints `Updating the ${label} install: ${root} (${providers})`, linked providers note, `Checking for updates...`; up to date → `Skills are up to date (vX).` [+hooks] + `Nothing else to do.`, exit 0; else `Found skills in: ...`, prompt `Update skills in N provider folder(s)? (Y/n) ` (n/no → `Aborted.` exit 0), refresh, `Updated N skill(s) to vX.`, `Done!`.
|
||||
- **link**: `--source=<path>` (default `.impeccable`), `--providers`, `--force`, `-y`. Source must contain `dist/universal/` or provider `*/skills` dirs, else `Could not find compiled skills in ${src}. Expected dist/universal/ or provider skill folders.` Prompts `Link impeccable skills into N folder(s)? (Y/n) `; creates relative dir symlinks; existing non-link skipped with warning unless `--force`; output `Linked impeccable into: ... (N linked, N already linked, N skipped).` + submodule hint.
|
||||
- **check**: not installed → `Impeccable is not installed in this project.` + `Run \`npx impeccable install\` to install.` exit 0; else `Checking for updates...\n` then `Skills are up to date (vX).` or `Updates available.` + `Run \`npx impeccable update\` to update.`; failure → `Could not check for updates: ${msg}` exit 1. A home-rooted check uses user scope, matching `update --global`: provider-specific canonical global paths are compared, while stale legacy duplicates such as `~/.pi/skills` do not create false update notices.
|
||||
- **check**: not installed → `Impeccable is not installed in this project.` + `Run \`npx impeccable install\` to install.` exit 0; else `Checking for updates...\n` then `Skills are up to date (vX).` or `Updates available.` + `Run \`npx impeccable update\` to update.`; failure → `Could not check for updates: ${msg}` exit 1.
|
||||
- Prompts: non-TTY `ask()` reads answers line-by-line from stdin (fd 0) after echoing the question; TTY SIGINT → `PromptAbortError` (`code IMPECCABLE_PROMPT_ABORT`) → cli.js prints `\nAborted.` exit 130. ANSI (`\x1b[36m` accent, `\x1b[1m` bold, `\x1b[2m` dim, `\x1b[32m` good) only when stdout is TTY, `NO_COLOR` unset, `TERM !== 'dumb'`.
|
||||
- Tests: `tests/skills-cli.test.js`, `tests/cli-remote-e2e` (opt-in).
|
||||
|
||||
|
||||
+121
@@ -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
@@ -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
Reference in New Issue
Block a user