Compare commits

..
Author SHA1 Message Date
Paul BakausandClaude Fable 5.1 d3b58b02a3 The probe's two text-rect helpers say which text they read
`__textLineRects` collected only the direct text once the line probe grew
its own deep walk, so its name promised lines it no longer produced: it is
`__directTextRects`, the list the union rect is built from. The snapshot's
union helper takes the rects rather than the node now, and its comment says
so. No behavior.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-20 20:03:28 -07:00
Paul BakausandClaude Fable 5.1 c97ed90df0 A compound separator pairs the two sides too
"Do's, and Don'ts" and "Do and/or Don't" put a comma and a conjunction, or
a conjunction and a slash, between the two halves, and a joiner that
accepted exactly one token read those headings as negative and dropped the
classes their "Do" sections sanction. One joiner or several, up to four.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-20 19:51:21 -07:00
Paul BakausandClaude Fable 5.1 6233ba2e89 A heading names both sides only when something joins them
`DESIGN_BOTH_SIDES_HEADING` asked for a `do` within twelve characters of a
`don't`, which "Don't do this" and "What we don't do" both satisfy while
meaning only the one thing. A section headed either of those stopped
condemning the classes it names, so its counter-examples became declared
components and the rules stood down on exactly the misuse the heading
introduces.

The two sides have to be joined by something that pairs them now — and,
or, a slash, a comma — which is what "Dos and Don'ts" and "Do / Do not"
have and a sentence that happens to contain both words does not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-20 19:39:48 -07:00
Paul BakausandClaude Fable 5.1 0fea696a7f A snapshot records each line once, a line stops at the gutter, and a design document's directive governs what follows it
The re-review of the first round found four places where the new
measurements reached further than they should.

- The snapshot recorded the rects of every text node *under* each element,
  so a line rendered deep in a page was written down again for every
  ancestor above it. On a deep, text-heavy page that multiplies the
  capture by its depth and can carry it past the byte cap, and a capture
  that fails is a scan that returns nothing at all. Each element records
  only its own text now — one line, one entry — and `text_line_rects`
  assembles an element's lines by walking the tree the capture already
  serialized.

- Merging rects by vertical band alone made two columns that happen to
  sit on the same rows into one page-wide line. A fragment joins a row now
  only when it runs on from it: a horizontal gap no wider than the row's
  own line box. The fragments of a wrapped line are contiguous; a gutter
  is not. An inline image wider than the leading splits its line by the
  same test, which understates a line rather than overstating it, and that
  is the direction this rule should err in.

- In DESIGN.md, where the negative word sits decides what it governs. A
  state ("`.card-old` is deprecated") describes whatever its clause is
  about; a directive ("never use `.x`") condemns what follows it and
  nothing before it. Reading the whole clause for either lost the
  sanctioned half of "Use `.kicker` and never `.tagline`". The headings a
  document uses to retire a set — "Retired components", "Unsupported
  patterns" — are read as negative now, and a heading that names both
  sides ("Dos and Don'ts") heads a section of both, so its subsections are
  what say which is which.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-20 19:13:13 -07:00
Paul BakausandClaude Fable 5.1 d1f81fc1c7 A rendered line is the whole line: the fragments merge, the snapshot carries them or the rule stands down, an ignored subtree gets no vote in the palette, and a class a design document forbids declares nothing
The review threads on #840 found four ways the new rendered rules were
still measuring something other than what the reader sees.

- `line-length` read one `getClientRects()` box per direct text node and
  called each of them a rendered line. An inline `<strong>` in the middle
  of a sentence is its own text node, so one visual line arrived as two
  fragments and the paragraph's characters were divided between them —
  and the `<strong>`'s own text was never measured at all while its
  characters still counted toward `text_len`. Both halves of the measure
  are the same text now: the probe collects the rects of every text node
  under the element, and `Dom::text_line_rects` merges the ones that share
  a row back into the line they rendered as. A column of long lines split
  by inline markup used to charge nothing; it charges now, and a wrapped
  sentence in two fragments is one line, not two.

- The same function divided every rect by the line box to recover a line
  count. On live per-line rects that was double-counting: a leading
  tighter than the glyph box makes `round(height / line_box)` 2, and one
  long line pushed twice satisfied "at least two long lines". Nothing is
  divided any more, because nothing that reaches the rule is a union.

- The union was what a snapshot-backed scan had — the extension's
  offscreen document and any strict-CSP page — and a union of a long
  first line and a short tail is the same union as two even lines, so
  every line inferred from it was invented. The capture records the rects
  now (`dl`, with `textLines` on the snapshot saying it did), and a
  capture that did not answers `None`: the rule stands down rather than
  guessing. That is also what any other DOM that cannot split a wrapped
  run answers.

- `ai-color-palette` accumulated its tell hues before the scoped ignores
  ran. A cyan tell inside a `data-impeccable-ignore="ai-color-palette"`
  subtree opened the page-wide two-hue gate and charged neon ink on an
  element nobody had waived. Ignored content now gets no vote.

- `declared_component_selectors` took every backticked class in DESIGN.md
  as a declaration, including the ones the document writes down in order
  to forbid them. "Do not write a new `.hero-cta-primary`" exempted
  `.hero-cta-primary` from `kicker-above-heading` — the parser silencing
  exactly the misuse the document was written to catch. Each occurrence
  is read in the document's own structure now: the heading chain above it
  (a "Don't" section, and its subsections with it) and the clause it sits
  in, where a clause is cut on punctuation and on the phrases that turn a
  sentence around. "No ALL CAPS outside the `.eyebrow` class" still
  declares `.eyebrow`, because what "no" governs ends at "outside"; a
  class the document calls deprecated anywhere is declared nowhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-20 19:02:46 -07:00
Paul BakausandClaude Fable 5.1 a5df2b0826 The rendered rules measure what rendered: the line instead of the box that holds it, the space around the text instead of the declared padding, gray by chroma at its own lightness, the AI palette by two tell hues rather than one accent, and the eyebrow named as the element its own design document declares
Five browser rules were reporting something other than what the reader sees,
and a review that charges those numbers is charging noise.

- `line-length` measured `rect.width / (fontSize * 0.5)`, the box's capacity.
  A paragraph in a 1022px column whose text stops at 571px was charged 142
  characters a line it never rendered. The probe now hands back the client
  rects of the direct text one per line box (`direct_text_line_rects`), the
  characters divide between the lines in proportion to the ink each carries,
  and the charge needs more than one long line: the harm named is the eye
  tracking back to the start of the next line, which takes a column to do.
- `cramped-padding` read the declared padding. A 44px control with
  `padding: 0 16px` and a flex-centred label has 12px of air above the label
  and was charged "0px vertical padding"; the measurement is now the inset
  between the rendered text and the inside of the border box. Its wrapper half
  read a text-bearing child's border box the same way, so a `<td>` that fills
  its table and insets its own text counted as flush; it reads the text now.
- `gray-on-color` called anything under 0.85 relative luminance gray, which
  takes in every off-white: `#e8edf2` measures 0.84 there and 0.93 as
  lightness. Gray is now low chroma at the lightness the ink actually sits at
  (saturation, which is chroma normalized for lightness) and neither of the
  two neutral inks a coloured surface carries. The contrast check beside it is
  untouched, and the recorded vectors still pass.
- `ai-color-palette` charged every hue between 160° and 200° on a dark ground
  as neon, which lit one ordinary teal accent 18 places on a page with nothing
  wrong with it. A gradient in a tell hue is still the pattern on its own;
  flat neon ink on near-black waits for a second tell hue to turn up somewhere
  on the page, because one saturated accent on a dark system is an accent.
- `kicker-above-heading` reported against `body`, so a charged row had nothing
  to point at, and it fired on eyebrows a design document documents. It names
  the eyebrow element now, and stands down where the repository's DESIGN.md
  declares the class by name — the prose's backticked class selectors travel
  on the design-system config the colour and radius rules already read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-20 18:53:36 -07:00
53 changed files with 1471 additions and 187 deletions
+41 -11
View File
@@ -46,6 +46,35 @@ function __rectArray(r) {
return [r.x, r.y, r.width, r.height, r.top, r.right, r.bottom, r.left];
}
// The client rects of the non-blank text nodes under `node`, in document
// order. `deep` walks element children too: one line of prose is one line
// box however the markup splits it, and an inline <strong>, an <a> or a
// framework marker in the middle of a sentence is a separate text node whose
// rects belong to the same line. Nothing is merged here — the rects travel as
// the page gave them and the consumer groups them into lines (see
// merge_text_rects_into_lines in crates/foundation/src/browser/dom.rs).
function __collectTextRects(node, deep, out) {
for (const child of node.childNodes) {
if (child.nodeType === 3) {
if (!(child.textContent || '').trim()) continue;
const range = document.createRange();
range.selectNodeContents(child);
for (const rect of range.getClientRects()) {
if (rect.width >= 1 && rect.height >= 1) out.push(rect);
}
range.detach?.();
} else if (deep && child.nodeType === 1) {
__collectTextRects(child, true, out);
}
}
return out;
}
// The element's own direct text, unmerged: what the union rect is built from.
function __directTextRects(el) {
return __collectTextRects(__el(el), false, []);
}
const __impeccableDom = {
document_element() { return __intern(document.documentElement); },
body() { return __intern(document.body); },
@@ -181,17 +210,7 @@ const __impeccableDom = {
// getDirectTextRect(el) from the JS driver: union of the client rects of
// the element's non-blank direct text nodes.
direct_text_rect(el) {
const node = __el(el);
const rects = [];
for (const child of node.childNodes) {
if (child.nodeType !== 3 || !(child.textContent || '').trim()) continue;
const range = document.createRange();
range.selectNodeContents(child);
for (const rect of range.getClientRects()) {
if (rect.width >= 1 && rect.height >= 1) rects.push(rect);
}
range.detach?.();
}
const rects = __directTextRects(el);
if (rects.length === 0) return [];
const left = Math.min(...rects.map(r => r.left));
const top = Math.min(...rects.map(r => r.top));
@@ -199,4 +218,15 @@ const __impeccableDom = {
const bottom = Math.max(...rects.map(r => r.bottom));
return [left, top, right - left, bottom - top, top, right, bottom, left];
},
// Every rect of the element's rendered text, descendants included, flattened
// into eights. The scope is the element's whole text_content, which is the
// text a caller counts characters from; the caller merges the rects that
// share a row into the line they rendered on.
text_rects(el) {
const out = [];
for (const r of __collectTextRects(__el(el), true, [])) {
out.push(r.left, r.top, r.width, r.height, r.top, r.right, r.bottom, r.left);
}
return out;
},
};
+24 -7
View File
@@ -69,19 +69,26 @@ const __SNAP_DEFAULT_MAX_BYTES = 48 * 1024 * 1024;
function __snapRect4(r) { return [r.x, r.y, r.width, r.height]; }
function __snapNum(v) { return typeof v === 'number' ? v : null; }
// getDirectTextRect(el): union of the client rects of the element's
// non-blank direct text nodes (same measure as 10-probe.js).
function __snapDirectTextRect(node) {
const rects = [];
// The client rects of `node`'s own non-blank text nodes (same walk as
// 10-probe.js#__collectTextRects with `deep` off). Each element records only
// its own, so a line that rendered is recorded exactly once in a snapshot.
function __snapTextRects(node, out) {
for (const child of node.childNodes) {
if (child.nodeType !== 3 || !(child.textContent || '').trim()) continue;
if (child.nodeType !== 3) continue;
if (!(child.textContent || '').trim()) continue;
const range = document.createRange();
range.selectNodeContents(child);
for (const rect of range.getClientRects()) {
if (rect.width >= 1 && rect.height >= 1) rects.push(rect);
if (rect.width >= 1 && rect.height >= 1) out.push(rect);
}
range.detach?.();
}
return out;
}
// getDirectTextRect(el) over rects already collected: their union, as
// 10-probe.js#direct_text_rect builds it.
function __snapDirectTextRectOf(rects) {
if (rects.length === 0) return null;
const left = Math.min(...rects.map(r => r.left));
const top = Math.min(...rects.map(r => r.top));
@@ -630,8 +637,17 @@ const __impeccableSnapshot = {
rec.v = typeof el.checkVisibility === 'function'
? (el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }) ? 1 : 0)
: -1;
const dtr = __snapDirectTextRect(el);
// The element's OWN text rects, unmerged (`dl`), and their union
// (`d`, the long-standing field). Own and not the subtree's: every
// ancestor would otherwise carry a copy of every line under it, which
// on a deep text-heavy page multiplies the snapshot by its depth and
// can push it past the byte cap. The consumer walks the tree and
// assembles an element's lines from the rects its descendants each
// recorded once (`SnapshotDom::text_line_rects`).
const own = __snapTextRects(el, []);
const dtr = __snapDirectTextRectOf(own);
if (dtr) rec.d = dtr;
if (own.length) rec.dl = own.map(r => [r.x, r.y, r.width, r.height]);
if (el.isContentEditable) rec.e = true;
if (el.hidden) rec.h = true;
if (typeof el.id !== 'string') rec.i = true;
@@ -661,6 +677,7 @@ const __impeccableSnapshot = {
}
const snapshot = {
v: 1,
textLines: true,
hostname: location.hostname,
quirks: document.compatMode === 'BackCompat',
innerWidth: window.innerWidth,
+1
View File
@@ -224,6 +224,7 @@ pub fn serialize_design_system_for_browser(ds: Option<&DesignSystem>) -> Value {
"hasRadii": ds.has_radii,
"allowedRadii": radii,
"hasPillRadius": ds.has_pill_radius,
"declaredSelectors": ds.declared_selectors,
})
}
+46 -21
View File
@@ -77,31 +77,56 @@ fn legacy_kebab(value: &str) -> Option<String> {
}
fn normalized_kebab(value: &str) -> Option<String> {
let normalized = value
.to_lowercase()
.split(|c: char| !c.is_ascii_lowercase() && !c.is_ascii_digit())
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
(!normalized.is_empty()).then_some(normalized)
let lower = value.to_lowercase();
// replace runs of / \ . with '-'
let mut s = String::with_capacity(lower.len());
let mut in_sep = false;
for c in lower.chars() {
if c == '/' || c == '\\' || c == '.' {
if !in_sep {
s.push('-');
in_sep = true;
}
} else {
in_sep = false;
s.push(c);
}
}
// replace runs of [^a-z0-9-] with '-'
let mut t = String::with_capacity(s.len());
let mut in_bad = false;
for c in s.chars() {
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' {
in_bad = false;
t.push(c);
} else if !in_bad {
t.push('-');
in_bad = true;
}
}
// collapse -+
let mut u = String::with_capacity(t.len());
let mut in_dash = false;
for c in t.chars() {
if c == '-' {
if !in_dash {
u.push('-');
in_dash = true;
}
} else {
in_dash = false;
u.push(c);
}
}
// strip leading/trailing '-' (JS: /^-|-$/g -> one at each end; after collapse there is at most one)
let u = u.strip_prefix('-').unwrap_or(&u).to_string();
let u = u.strip_suffix('-').unwrap_or(&u).to_string();
(!u.is_empty()).then_some(u)
}
#[cfg(test)]
mod tests {
use super::{kebab, legacy_kebab, normalized_kebab, SLUG_MAX};
#[test]
fn normalization_preserves_ascii_runs_after_unicode_lowercasing() {
for (input, expected) in [
("", None),
(" /\\.--_!?\n🦀 ", None),
("--Button./\\ _Primary...99--", Some("button-primary-99")),
("A🦀B café", Some("a-b-caf")),
("İSTANBUL ELVIN", Some("i-stanbul-kelvin")),
] {
assert_eq!(normalized_kebab(input).as_deref(), expected, "{input:?}");
}
}
use super::{kebab, legacy_kebab, SLUG_MAX};
#[test]
fn truncated_slugs_keep_distinct_full_inputs_distinct() {
+152 -12
View File
@@ -78,6 +78,10 @@ pub struct DesignSeen {
/// `None` when `!raw?.present`.
#[derive(Debug, Clone, Default)]
pub struct DesignSystemConfig {
/// Selectors the repository's design document names as its own, e.g.
/// `.eyebrow` written into DESIGN.md. A rule that would charge one of
/// these is reviewing the design system rather than the change (REN-406).
pub declared_selectors: Vec<String>,
pub has_fonts: bool,
pub allowed_fonts: Vec<String>,
pub has_colors: bool,
@@ -217,8 +221,15 @@ pub fn browser_design_system_config(config: &BrowserConfig) -> Option<DesignSyst
.map(js_number)
.filter(|px| px.is_finite())
.collect();
let declared_selectors: Vec<String> = arr("declaredSelectors")
.iter()
.map(js_string_or_empty)
.map(|s| crate::js::trim(&s).to_string())
.filter(|s| !s.is_empty())
.collect();
let is_true = |k: &str| matches!(obj.get(k), Some(serde_json::Value::Bool(true)));
Some(DesignSystemConfig {
declared_selectors,
has_fonts: is_true("hasFonts") && !allowed_fonts.is_empty(),
allowed_fonts,
has_colors: is_true("hasColors") && !allowed_colors.is_empty(),
@@ -1318,6 +1329,11 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
let rule_ok = |id: &str| disabled.is_empty() || !disabled.iter().any(|d| d == id);
let design_system = browser_design_system_config(config);
let mut design_seen = DesignSeen::default();
// The AI palette is read over the whole page: neon ink on a near-black
// ground waits here until a second tell hue turns up somewhere, so one
// deliberate accent stays an accent (REN-405).
let mut palette_tells: Vec<ec::TellHue> = Vec::new();
let mut palette_ink: Vec<(ElId, BrowserFinding)> = Vec::new();
let body = dom.body();
let root = dom.document_element();
// JS `document.body` may be null on a bare document; every
@@ -1353,7 +1369,19 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
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)));
findings.extend(hits(ec::check_element_ai_palette_dom(dom, el)));
let palette = ec::check_element_ai_palette_dom(dom, el);
// An ignored subtree gets no vote in the page-wide reading. A cyan
// tell inside `data-impeccable-ignore="ai-color-palette"` would
// otherwise open the two-hue gate and charge neon ink somewhere else
// on the page that nobody waived — ignored content changing the
// result for content that was not ignored.
if !scoped_ignore_active(dom, el, "ai-color-palette") {
palette_tells.extend(palette.tells.iter().copied());
}
if let Some(ink) = palette.ink {
palette_ink.push((el, BrowserFinding::new(ink.id, ink.snippet)));
}
findings.extend(hits(palette.hits));
findings.extend(hits(ec::check_element_radial_spotlight_dom(dom, el)));
findings.extend(hits(ec::check_element_icon_tile_dom(dom, el)));
findings.extend(hits(ec::check_element_italic_serif_dom(dom, el)));
@@ -1390,6 +1418,17 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
}
}
// Two different tell hues on one page is the palette; one is an accent.
if palette_tells.iter().any(|t| *t == ec::TellHue::Cyan)
&& palette_tells.iter().any(|t| *t == ec::TellHue::Purple)
{
for (el, finding) in palette_ink {
if rule_ok(&finding.type_) {
add_browser_findings(dom, &mut groups, el, vec![finding]);
}
}
}
let page_pass = |groups: &mut Vec<FindingGroup>, page_level: &mut Vec<BrowserFinding>, list: Vec<BrowserFinding>| {
let list: Vec<BrowserFinding> = list.into_iter().filter(|f| rule_ok(&f.type_)).collect();
if !list.is_empty() {
@@ -1398,17 +1437,6 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
}
};
page_pass(
&mut groups,
&mut page_level,
check_browser_design_system_sources(dom, design_system.as_ref(), &mut design_seen),
);
page_pass(&mut groups, &mut page_level, pc::check_typography(dom));
page_pass(&mut groups, &mut page_level, hits(tc::check_kicker_above_heading_dom(dom)));
page_pass(&mut groups, &mut page_level, hits(tc::check_numbered_section_labels_dom(dom)));
page_pass(&mut groups, &mut page_level, hits(tc::check_repeated_container_text_dom(dom)));
page_pass(&mut groups, &mut page_level, hits(tc::check_em_dash_overuse_dom(dom)));
let el_pass = |groups: &mut Vec<FindingGroup>, list: Vec<super::ElFinding>| {
for f in list {
if !rule_ok(&f.finding.type_) {
@@ -1423,6 +1451,18 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
);
}
};
page_pass(
&mut groups,
&mut page_level,
check_browser_design_system_sources(dom, design_system.as_ref(), &mut design_seen),
);
page_pass(&mut groups, &mut page_level, pc::check_typography(dom));
el_pass(&mut groups, tc::check_kicker_above_heading_dom(dom, design_system.as_ref()));
page_pass(&mut groups, &mut page_level, hits(tc::check_numbered_section_labels_dom(dom)));
page_pass(&mut groups, &mut page_level, hits(tc::check_repeated_container_text_dom(dom)));
page_pass(&mut groups, &mut page_level, hits(tc::check_em_dash_overuse_dom(dom)));
el_pass(&mut groups, pc::check_layout(dom));
el_pass(&mut groups, pc::check_heading_rhythm_dom(dom));
el_pass(&mut groups, pc::check_edge_flush_cards_dom(dom));
@@ -1638,6 +1678,57 @@ mod tests {
assert!(!is_likely_hashed_class("abcdefg"));
}
/// REN-405. Northwind's Slate system: near-black ground, light ink, one
/// teal accent. The accent lit 18 places on a page with nothing wrong with
/// it. It stays quiet until the page shows the other half of the palette.
#[test]
fn one_accent_hue_on_dark_is_not_the_ai_palette() {
let build = |gradient: bool| {
let mut d = FakeDom::new();
let (_html, body) = d.with_page();
d.set_style(body, "backgroundColor", "rgb(15, 18, 17)");
d.set_rect(body, 0.0, 0.0, 1440.0, 900.0);
for i in 0..3 {
let a = d.add(Some(body), "a");
d.add_text(a, "Open the ledger");
d.set_rect(a, 40.0, 40.0 + 30.0 * (i as f64), 160.0, 20.0);
d.set_styles(a, &[("color", "rgb(47, 184, 166)")]);
}
if gradient {
let hero = d.add(Some(body), "div");
d.set_rect(hero, 0.0, 200.0, 1440.0, 320.0);
d.set_style(
hero,
"backgroundImage",
"linear-gradient(135deg, rgb(124, 58, 237) 0%, rgb(168, 85, 247) 100%)",
);
}
d
};
let ids = |d: &FakeDom| {
collect_browser_findings(d, &BrowserConfig::default())
.groups
.iter()
.flat_map(|g| g.findings.iter())
.filter(|f| f.type_ == "ai-color-palette")
.map(|f| f.detail.clone())
.collect::<Vec<_>>()
};
// One teal accent on near-black: an accent.
assert_eq!(ids(&build(false)), Vec::<String>::new());
// The same accent beside a purple gradient: the palette, and every
// place it shows is named.
assert_eq!(
ids(&build(true)),
vec![
"Purple/violet gradient background".to_string(),
"Cyan neon text on dark background".to_string(),
"Cyan neon text on dark background".to_string(),
"Cyan neon text on dark background".to_string(),
]
);
}
#[test]
fn skip_scan_empties_the_collect_pass() {
// JS: index.mjs#skipScanActive() — an ignoreFiles-waived page answers
@@ -1847,6 +1938,55 @@ mod tests {
assert_eq!(html_pattern_query(".a::before,"), Some(".a".to_string()));
}
/// An ignored subtree does not get to open the page-wide palette gate.
/// `ai-color-palette` holds neon ink until a second tell hue turns up
/// somewhere on the page; a cyan tell inside a
/// `data-impeccable-ignore="ai-color-palette"` subtree used to count
/// toward that, so waiving one component charged an unrelated one.
#[test]
fn ignored_colors_do_not_contribute_tell_hues() {
let build = |ignore: bool| {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
d.set_style(body, "backgroundColor", "rgb(5, 6, 10)");
// The waived component: cyan neon ink on near-black.
let demo = d.add(Some(body), "div");
d.set_style(demo, "backgroundColor", "rgb(5, 6, 10)");
if ignore {
d.set_attr(demo, "data-impeccable-ignore", "ai-color-palette");
}
let cyan = d.add(Some(demo), "span");
d.add_text(cyan, "Terminal output");
d.set_style(cyan, "color", "rgb(34, 238, 238)");
d.set_style(cyan, "backgroundColor", "rgba(0, 0, 0, 0)");
d.el_mut(cyan).check_visibility = Some(true);
// Somewhere else on the page, and waived by nobody.
let card = d.add(Some(body), "div");
d.set_style(card, "backgroundColor", "rgb(5, 6, 10)");
let purple = d.add(Some(card), "span");
d.add_text(purple, "Upgrade");
d.set_style(purple, "color", "rgb(180, 60, 245)");
d.set_style(purple, "backgroundColor", "rgba(0, 0, 0, 0)");
d.el_mut(purple).check_visibility = Some(true);
d
};
let charged = |d: &FakeDom| -> Vec<String> {
collect_browser_findings(d, &BrowserConfig::default())
.groups
.iter()
.flat_map(|g| g.findings.iter().map(|f| f.type_.clone()))
.filter(|t| t == "ai-color-palette")
.collect()
};
// Two tell hues, neither waived: the palette is the page's.
assert_eq!(charged(&build(false)).len(), 2);
// The cyan half waived: one tell hue is an accent, and the purple ink
// outside the ignored subtree is not charged either.
assert!(charged(&build(true)).is_empty());
}
#[test]
fn scoped_ignore_and_visual_merge() {
let mut d = FakeDom::new();
+66 -28
View File
@@ -716,24 +716,67 @@ pub fn check_element_glow_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
})
}
/// The two hues the AI palette is built out of. A page that uses one of them
/// has an accent; a page that uses both has the palette.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TellHue {
Cyan,
Purple,
}
impl TellHue {
/// The band a colour falls in, `None` outside both.
fn of(hue: f64) -> Option<TellHue> {
if (160.0..=200.0).contains(&hue) {
Some(TellHue::Cyan)
} else if (260.0..=310.0).contains(&hue) {
Some(TellHue::Purple)
} else {
None
}
}
fn label(self) -> &'static str {
match self {
TellHue::Cyan => "Cyan",
TellHue::Purple => "Purple/violet",
}
}
}
/// What one element contributes to the AI-palette reading.
#[derive(Debug, Clone, Default)]
pub struct AiPaletteReading {
/// Charged where they are found: a saturated cyan or purple *gradient* is
/// the pattern by itself, whatever else the page does.
pub hits: Vec<RuleHit>,
/// Neon ink on a near-black ground, held until a second tell hue shows up
/// somewhere on the page (REN-405).
pub ink: Option<RuleHit>,
/// The tell hues this element showed, gradient and ink alike.
pub tells: Vec<TellHue>,
}
/// JS: checks.mjs#checkElementAIPaletteDOM(el)
pub fn check_element_ai_palette_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
let mut findings = Vec::new();
///
/// One element's reading. The gradient half answers on its own; the ink half
/// is held for the page pass, because a single saturated hue on a dark ground
/// is how a great many ordinary systems draw their one accent — a teal
/// `#2fb8a6` on near-black lit 18 places on the bench's base, and every one of
/// them was the same deliberate accent (REN-405). Two different tell hues on
/// one page is the palette the rule is named for.
pub fn check_element_ai_palette_dom(dom: &dyn Dom, el: ElId) -> AiPaletteReading {
let mut reading = AiPaletteReading::default();
let bg_image = dom.style(el, "backgroundImage");
for c in parse_gradient_colors(Some(&bg_image)) {
if has_chroma(Some(&c), Some(50.0)) {
let hue = get_hue(Some(&c));
if hue >= 260.0 && hue <= 310.0 {
findings.push(RuleHit::new(
if let Some(tell) = TellHue::of(get_hue(Some(&c))) {
reading.tells.push(tell);
reading.hits.push(RuleHit::new(
"ai-color-palette",
"Purple/violet gradient background".to_string(),
));
break;
}
if hue >= 160.0 && hue <= 200.0 {
findings.push(RuleHit::new(
"ai-color-palette",
"Cyan gradient background".to_string(),
match tell {
TellHue::Purple => "Purple/violet gradient background".to_string(),
TellHue::Cyan => "Cyan gradient background".to_string(),
},
));
break;
}
@@ -742,10 +785,7 @@ pub fn check_element_ai_palette_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
let text_color = parse_rgb_or_any(&dom.style(el, "color"));
if let Some(tc) = text_color {
if has_chroma(Some(&tc), Some(80.0)) {
let hue = get_hue(Some(&tc));
let is_ai_palette =
(hue >= 160.0 && hue <= 200.0) || (hue >= 260.0 && hue <= 310.0);
if is_ai_palette {
if let Some(tell) = TellHue::of(get_hue(Some(&tc))) {
let parent = dom.parent(el);
let parent_bg_info = match parent {
Some(p) => resolve_background_info(dom, p),
@@ -760,21 +800,17 @@ pub fn check_element_ai_palette_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
}
if let Some(bg) = effective_bg {
if relative_luminance(&bg) < 0.1 {
let label = if hue >= 260.0 {
"Purple/violet"
} else {
"Cyan"
};
findings.push(RuleHit::new(
reading.tells.push(tell);
reading.ink = Some(RuleHit::new(
"ai-color-palette",
format!("{label} neon text on dark background"),
format!("{} neon text on dark background", tell.label()),
));
}
}
}
}
}
findings
reading
}
// ── radial spotlight ──────────────────────────────────────────────────────
@@ -1534,9 +1570,11 @@ mod tests {
"linear-gradient(rgb(168, 85, 247), rgb(59, 130, 246))",
);
d.set_style(hero, "color", "rgb(0, 0, 0)");
let hits = check_element_ai_palette_dom(&d, hero);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].snippet, "Purple/violet gradient background");
let reading = check_element_ai_palette_dom(&d, hero);
assert_eq!(reading.hits.len(), 1);
assert_eq!(reading.hits[0].snippet, "Purple/violet gradient background");
assert!(reading.ink.is_none());
assert_eq!(reading.tells, vec![TellHue::Purple]);
}
#[test]
+391 -27
View File
@@ -92,7 +92,30 @@ pub fn has_meaningful_direct_text(dom: &dyn Dom, el: ElId) -> bool {
has_direct_text_longer_than(dom, el, 4)
}
/// The width of every line the element's text rendered on, or `None` when
/// the DOM cannot say where the lines are.
///
/// `Dom::text_line_rects` has already merged the fragments of a line back
/// together, so each rect here is one line box and nothing is divided by
/// anything: a leading tighter than the glyph box used to turn one rect into
/// two identical "lines" and charge a single long line twice.
fn rendered_line_widths(dom: &dyn Dom, el: ElId) -> Option<Vec<f64>> {
Some(
dom.text_line_rects(el)?
.into_iter()
.filter(|r| r.width > 0.0 && r.height > 0.0)
.map(|r| r.width)
.collect(),
)
}
/// JS: checks.mjs#textDescendantsFlushSides(el, rect) → [top, right, bottom, left]
///
/// The side is flush when the *text* lands on it, not when a text-bearing box
/// does. A `<td>` fills its table edge to edge and insets its own text by the
/// cell padding; reading the cell's border box called that flush and charged a
/// framed table for having no inset, when the reader sees the padding the
/// cells declare (REN-403).
pub fn text_descendants_flush_sides(dom: &dyn Dom, el: ElId, rect: &Rect) -> [bool; 4] {
let mut flush = [false; 4];
const TEXT_EDGE_THRESHOLD: f64 = 4.0;
@@ -102,7 +125,7 @@ pub fn text_descendants_flush_sides(dom: &dyn Dom, el: ElId, rect: &Rect) -> [bo
if !TEXT_EDGE_TAGS.contains(&tag_name.as_str()) || !has_meaningful_direct_text(dom, node) {
continue;
}
let nr = dom.rect(node);
let nr = dom.direct_text_rect(node).unwrap_or_else(|| dom.rect(node));
if nr.width <= 0.0 || nr.height <= 0.0 {
continue;
}
@@ -245,21 +268,55 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec<RuleHit> {
}
// --- Line length too long ---
//
// The measure is the line that rendered, not the box that could have held
// it. `rect.width / (fontSize * 0.5)` is the box's capacity: a paragraph
// sitting in a 1022px column whose text stops at 571px was charged with
// 142 characters a line it never rendered (REN-402). What the reader sees
// is `text_line_rects`, one rect per line box with the fragments of a
// line merged back together, and the characters divide between the lines
// in proportion to the ink each carries — one element's text is one font
// at one size, so the average advance is the same on every line of it.
//
// The rects cover the element's whole rendered text, descendants and all,
// which is the same text `text_len` counts: measuring the direct text
// alone and then charging it with the characters of an inline `<strong>`
// inflated every paragraph that had one.
//
// A DOM that cannot say where the lines are gets no finding. The union of
// a long first line and a short tail is the same union as two even lines,
// so there is nothing in it to read a line off, and a rule that guesses
// there is charging noise.
//
// Charged when at least two rendered lines run past the maximum. The harm
// this rule names is the eye losing its place tracking back to the start
// of the next line, so it takes a column of long lines to do the damage;
// one long line and a short tail is a sentence that wrapped once.
if has_direct_text
&& QUALITY_TEXT_TAGS.contains(&tag)
&& rect.width > 0.0
&& (text_len as f64) > line_max
{
let chars_per_line = rect.width / (font_size * 0.5);
if chars_per_line > line_max + 5.0 {
findings.push(RuleHit::new(
"line-length",
format!(
"~{} chars/line (aim for <{})",
number_to_string(math_round(chars_per_line)),
number_to_string(line_max)
),
));
if let Some(widths) = rendered_line_widths(dom, el) {
let total: f64 = widths.iter().sum();
if total > 0.0 {
let over = line_max + 5.0;
let chars = |w: f64| (text_len as f64) * w / total;
let long = widths.iter().filter(|w| chars(**w) > over).count();
if long >= 2 {
let longest = widths.iter().copied().fold(0.0, js::math_max);
findings.push(RuleHit::new(
"line-length",
format!(
"~{} chars on {} of {} rendered lines (aim for <{})",
number_to_string(math_round(chars(longest))),
number_to_string(long as f64),
number_to_string(widths.len() as f64),
number_to_string(line_max)
),
));
}
}
}
}
@@ -274,31 +331,43 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec<RuleHit> {
];
let border_count = borders.iter().filter(|w| **w > 0.0).count();
let has_bg = has_visible_background_boundary(dom, el);
if border_count >= 2 || has_bg {
// The space the reader sees, not the space the stylesheet declares:
// the inset between the rendered text and the inside of the border
// box. A 44px control with `padding: 0 16px` whose label a flex box
// centres has 12px of air above the label and was charged with "0px
// vertical padding" (REN-403). Nothing to measure the text with is
// nothing to charge on, and the text is measured only for the
// elements that got this far: the probe builds a Range per call, and
// a page has a great many boxes that are not bounded at all.
if let Some(text_rect) = (border_count >= 2 || has_bg)
.then(|| dom.direct_text_rect(el))
.flatten()
{
let mut v_pads: Vec<f64> = Vec::new();
let mut h_pads: Vec<f64> = Vec::new();
if has_bg || borders[0] > 0.0 {
v_pads.push(spx("paddingTop"));
v_pads.push(text_rect.top - (rect.top + borders[0]));
}
if has_bg || borders[2] > 0.0 {
v_pads.push(spx("paddingBottom"));
v_pads.push((rect.bottom - borders[2]) - text_rect.bottom);
}
if has_bg || borders[3] > 0.0 {
h_pads.push(spx("paddingLeft"));
h_pads.push(text_rect.left - (rect.left + borders[3]));
}
if has_bg || borders[1] > 0.0 {
h_pads.push(spx("paddingRight"));
h_pads.push((rect.right - borders[1]) - text_rect.right);
}
let v_min = v_pads.iter().copied().fold(f64::INFINITY, js::math_min);
let h_min = h_pads.iter().copied().fold(f64::INFINITY, js::math_min);
let v_thresh = js::math_max(4.0, font_size * 0.3);
let h_thresh = js::math_max(8.0, font_size * 0.5);
let px = |v: f64| number_to_string(math_round(v * 10.0) / 10.0);
if v_min < v_thresh {
findings.push(RuleHit::new(
"cramped-padding",
format!(
"{}px vertical padding (need ≥{}px for {}px text)",
number_to_string(v_min),
"{}px of space above and below the text (need ≥{}px for {}px text)",
px(v_min),
to_fixed(v_thresh, 1),
number_to_string(font_size)
),
@@ -307,8 +376,8 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec<RuleHit> {
findings.push(RuleHit::new(
"cramped-padding",
format!(
"{}px horizontal padding (need ≥{}px for {}px text)",
number_to_string(h_min),
"{}px of space beside the text (need ≥{}px for {}px text)",
px(h_min),
to_fixed(h_thresh, 1),
number_to_string(font_size)
),
@@ -380,6 +449,13 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec<RuleHit> {
];
const PAD_THRESHOLD: f64 = 2.0;
const CHILD_INSULATE_THRESHOLD: f64 = 4.0;
// Content that runs past the box is clipped, not snug. A
// table with a min-width inside an `overflow: hidden` frame
// has its far column cut off, which is a defect
// `clipped-overflow-container` is named for; calling it "no
// inset" points at the wrong thing (REN-403).
const OVERFLOW_TOLERANCE: f64 = 1.0;
let mut children_overflow = [false; 4];
let mut children_insulate = [false; 4];
for &child in &children {
let child_pad = [
@@ -396,6 +472,18 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec<RuleHit> {
];
let cr = dom.rect(child);
if cr.width > 0.0 && cr.height > 0.0 {
if rect.top - cr.top > OVERFLOW_TOLERANCE {
children_overflow[0] = true;
}
if cr.right - rect.right > OVERFLOW_TOLERANCE {
children_overflow[1] = true;
}
if cr.bottom - rect.bottom > OVERFLOW_TOLERANCE {
children_overflow[2] = true;
}
if rect.left - cr.left > OVERFLOW_TOLERANCE {
children_overflow[3] = true;
}
if cr.top - rect.top >= CHILD_INSULATE_THRESHOLD {
children_insulate[0] = true;
}
@@ -428,7 +516,12 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec<RuleHit> {
for s in 0..4 {
let bg_bounds_side = bg_visible && !(full_bleed_bg_band && (s == 1 || s == 3));
let side_bounded = border_visible[s] || outline_visible || bg_bounds_side;
if side_bounded && pad[s] <= PAD_THRESHOLD && !children_insulate[s] && text_flush[s] {
if side_bounded
&& pad[s] <= PAD_THRESHOLD
&& !children_insulate[s]
&& !children_overflow[s]
&& text_flush[s]
{
flush_sides.push(side_names[s]);
}
}
@@ -766,22 +859,176 @@ mod tests {
fn line_length_and_viewport_edge() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let long = "x".repeat(120);
let long = "x".repeat(240);
let p = text_el(&mut d, body, "p", &long, "16px");
d.set_rect(p, 0.0, 100.0, 1200.0, 40.0);
d.set_rect(p, 0.0, 100.0, 1200.0, 72.0);
// Three rendered lines: two full ones and a tail.
d.set_text_lines(p, &[(0.0, 100.0, 1180.0, 19.0), (0.0, 124.0, 1180.0, 19.0), (0.0, 148.0, 400.0, 19.0)]);
let hits = check_element_quality_dom(&d, p, &BrowserConfig::default());
let ids: Vec<&str> = hits.iter().map(|h| h.id.as_str()).collect();
assert!(ids.contains(&"line-length"), "{ids:?}");
assert_eq!(hits[0].snippet, "~150 chars/line (aim for <80)");
assert_eq!(hits[0].snippet, "~103 chars on 2 of 3 rendered lines (aim for <80)");
assert!(ids.contains(&"body-text-viewport-edge"));
let edge = hits.iter().find(|h| h.id == "body-text-viewport-edge").unwrap();
assert_eq!(edge.snippet, "<p> with 120-char body bleeds to viewport edge (left 0px)");
assert_eq!(edge.snippet, "<p> with 240-char body bleeds to viewport edge (left 0px)");
// narrower, inset paragraph: neither fires
d.set_rect(p, 40.0, 100.0, 600.0, 40.0);
d.set_rect(p, 40.0, 100.0, 600.0, 72.0);
d.set_text_lines(p, &[(40.0, 100.0, 580.0, 19.0), (40.0, 124.0, 580.0, 19.0), (40.0, 148.0, 580.0, 19.0)]);
let hits = check_element_quality_dom(&d, p, &BrowserConfig::default());
assert!(hits.is_empty(), "{hits:?}");
}
/// REN-402. Halfday's pricing copy: a 158-character paragraph in a 1022px
/// card body, rendering 145 characters on its first line and 13 on its
/// second. The old measurement charged the box (`1022 / (15 * 0.5)` = 136
/// "chars/line") on every paragraph that shape, including the ones whose
/// text stops well short of the box.
#[test]
fn line_length_reads_the_rendered_line_not_the_box() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let copy = "People are counted on the first of the month. Someone invited on the 3rd is free until the 1st, and someone removed mid-month is credited on the next invoice.";
assert_eq!(copy.chars().count(), 158);
let p = text_el(&mut d, body, "p", copy, "15px");
d.set_style(p, "lineHeight", "24px");
d.set_rect(p, 200.0, 971.0, 1022.0, 48.0);
// One long line and a 13-character tail: the eye tracks back once.
d.set_text_lines(p, &[(200.0, 974.0, 995.4, 18.0), (200.0, 998.0, 85.5, 18.0)]);
assert_eq!(check_element_quality_dom(&d, p, &BrowserConfig::default()), vec![]);
// The same box, text that stops at 571px: 89 characters on one line.
let meta = text_el(&mut d, body, "p", &"y".repeat(89), "14px");
d.set_style(meta, "lineHeight", "21.7px");
d.set_rect(meta, 200.0, 122.0, 992.0, 21.7);
d.set_text_lines(meta, &[(200.0, 124.2, 571.4, 17.0)]);
assert_eq!(check_element_quality_dom(&d, meta, &BrowserConfig::default()), vec![]);
// A column of long lines is the defect the rule is named for.
let wall = text_el(&mut d, body, "p", &"z".repeat(500), "15px");
d.set_style(wall, "lineHeight", "24px");
d.set_rect(wall, 200.0, 100.0, 1022.0, 96.0);
d.set_text_lines(
wall,
&[
(200.0, 100.0, 1000.0, 18.0),
(200.0, 124.0, 1000.0, 18.0),
(200.0, 148.0, 1000.0, 18.0),
(200.0, 172.0, 600.0, 18.0),
],
);
let hits = check_element_quality_dom(&d, wall, &BrowserConfig::default());
assert_eq!(hits.len(), 1, "{hits:?}");
assert_eq!(hits[0].snippet, "~139 chars on 3 of 4 rendered lines (aim for <80)");
}
/// A line box split across text nodes is still one line. An inline
/// `<strong>` or `<a>` in the middle of a sentence is its own text node,
/// so `getClientRects()` hands back a rect per fragment; counting each
/// fragment as a line divided the paragraph's characters among them and
/// hid a genuinely long column.
#[test]
fn a_line_split_across_fragments_is_one_line() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
// 300 characters over three rendered lines, each interrupted mid-line
// by an inline element and so measured in two pieces.
let p = text_el(&mut d, body, "p", &"w".repeat(300), "16px");
d.set_rect(p, 0.0, 100.0, 1020.0, 72.0);
d.set_text_lines(
p,
&[
(0.0, 100.0, 520.0, 19.0),
(520.0, 100.0, 480.0, 19.0),
(0.0, 124.0, 510.0, 19.0),
(510.0, 124.0, 490.0, 19.0),
(0.0, 148.0, 505.0, 19.0),
(505.0, 148.0, 495.0, 19.0),
],
);
let hits = check_element_quality_dom(&d, p, &BrowserConfig::default());
let line = hits.iter().find(|h| h.id == "line-length").expect("charged");
// Three lines of 1000px, not six of ~500: six would have put 50
// characters on each and charged nothing at all.
assert_eq!(line.snippet, "~100 chars on 3 of 3 rendered lines (aim for <80)");
// The same merge the other way: one long line in two fragments plus a
// short tail is two lines, and one long line is a sentence that
// wrapped once.
let q = text_el(&mut d, body, "p", &"w".repeat(190), "16px");
d.set_rect(q, 0.0, 300.0, 1020.0, 48.0);
d.set_text_lines(
q,
&[
(0.0, 300.0, 600.0, 19.0),
(600.0, 300.0, 400.0, 19.0),
(0.0, 324.0, 120.0, 19.0),
],
);
let hits = check_element_quality_dom(&d, q, &BrowserConfig::default());
assert!(!hits.iter().any(|h| h.id == "line-length"), "{hits:?}");
}
/// Two columns that happen to sit on the same rows are two flows, not one
/// page-wide line. Fragments join a row only when they run on from it —
/// a gap no wider than the row's own line box — so a gutter keeps them
/// apart and the characters stay where the reader sees them.
#[test]
fn columns_on_the_same_rows_are_separate_lines() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let p = text_el(&mut d, body, "p", &"w".repeat(240), "16px");
d.set_rect(p, 0.0, 100.0, 1020.0, 72.0);
// Two 300px columns with a 100px gutter, three rows each.
d.set_text_lines(
p,
&[
(0.0, 100.0, 300.0, 19.0),
(400.0, 100.0, 300.0, 19.0),
(0.0, 124.0, 300.0, 19.0),
(400.0, 124.0, 300.0, 19.0),
(0.0, 148.0, 300.0, 19.0),
(400.0, 148.0, 300.0, 19.0),
],
);
let hits = check_element_quality_dom(&d, p, &BrowserConfig::default());
// Six short lines of 40 characters each, not three of 700px.
assert!(!hits.iter().any(|h| h.id == "line-length"), "{hits:?}");
}
/// A rect that is already one line is one line, whatever the leading is.
/// Dividing every rect by the line box turned a paragraph whose leading
/// is tighter than its glyph box into two copies of the same line, and
/// two copies of one long line satisfied "at least two long lines".
#[test]
fn tight_leading_does_not_double_count_a_line() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let p = text_el(&mut d, body, "p", &"w".repeat(200), "15px");
// 10px of leading under an 19px glyph box: `round(19 / 10)` is 2.
d.set_style(p, "lineHeight", "10px");
d.set_rect(p, 0.0, 100.0, 1020.0, 19.0);
d.set_text_lines(p, &[(0.0, 100.0, 1000.0, 19.0)]);
let hits = check_element_quality_dom(&d, p, &BrowserConfig::default());
assert!(!hits.iter().any(|h| h.id == "line-length"), "{hits:?}");
}
/// A DOM that kept only the union of its text rects cannot say where the
/// lines are, and the rule stands down rather than inventing them. A
/// snapshot captured before the lines were recorded is that DOM: the
/// union of a long first line and a short tail is the same union as two
/// even lines, so any width read off it is a width nothing rendered.
#[test]
fn a_dom_without_lines_does_not_charge_line_length() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let p = text_el(&mut d, body, "p", &"w".repeat(300), "16px");
d.set_rect(p, 0.0, 100.0, 1020.0, 72.0);
// The same paragraph the merge test charges, measured once.
d.set_text_rect(p, 0.0, 100.0, 1000.0, 67.0);
let hits = check_element_quality_dom(&d, p, &BrowserConfig::default());
assert!(!hits.iter().any(|h| h.id == "line-length"), "{hits:?}");
}
#[test]
fn cramped_padding_vertical() {
let mut d = FakeDom::new();
@@ -803,9 +1050,54 @@ mod tests {
("paddingRight", "12px"),
],
);
// The text lands 2px under the top edge, which is what the reader sees
// and what the declared padding happens to say here.
d.set_text_lines(p, &[(52.0, 102.0, 276.0, 19.0)]);
let hits = check_element_quality_dom(&d, p, &BrowserConfig::default());
assert_eq!(hits.len(), 1, "{hits:?}");
assert_eq!(hits[0].snippet, "2px vertical padding (need ≥4.8px for 16px text)");
assert_eq!(
hits[0].snippet,
"2px of space above and below the text (need ≥4.8px for 16px text)"
);
}
/// REN-403. Halfday's plan button: 44px tall because the design system
/// says every control is, `padding: 0 16px`, and the label optically
/// centred by a flex box. The declared vertical padding is zero and the
/// space above the label is 12px, which is what the reader sees.
#[test]
fn cramped_padding_measures_the_space_around_the_text() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
d.set_style(body, "backgroundColor", "rgb(255, 255, 255)");
let btn = text_el(&mut d, body, "a", "Talk to us about Studio", "15px");
d.set_rect(btn, 0.0, 778.7, 296.7, 44.0);
d.set_styles(
btn,
&[
("backgroundColor", "rgb(255, 255, 255)"),
("borderTopWidth", "1px"),
("borderRightWidth", "1px"),
("borderBottomWidth", "1px"),
("borderLeftWidth", "1px"),
("paddingTop", "0px"),
("paddingBottom", "0px"),
("paddingLeft", "16px"),
("paddingRight", "16px"),
("display", "flex"),
],
);
d.set_text_lines(btn, &[(68.0, 791.2, 160.0, 18.0)]);
assert_eq!(check_element_quality_dom(&d, btn, &BrowserConfig::default()), vec![]);
// The same control with the label actually against the edge: charged.
d.set_text_lines(btn, &[(68.0, 780.2, 160.0, 18.0)]);
let hits = check_element_quality_dom(&d, btn, &BrowserConfig::default());
assert_eq!(hits.len(), 1, "{hits:?}");
assert_eq!(
hits[0].snippet,
"0.5px of space above and below the text (need ≥4.5px for 15px text)"
);
}
#[test]
@@ -847,6 +1139,78 @@ mod tests {
);
}
/// REN-403, the wrapper half of the same rule. Crewline's framed table:
/// the `<table>` fills the frame edge to edge, and every cell insets its
/// own text by the padding the stylesheet gives it. Reading the cell's
/// border box called all four sides flush.
#[test]
fn flush_reads_the_text_not_the_cell_that_holds_it() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
d.set_style(body, "backgroundColor", "rgb(255, 255, 255)");
let frame = d.add(Some(body), "div");
d.set_attr(frame, "class", "table-frame");
d.set_rect(frame, 0.0, 0.0, 860.0, 300.0);
d.set_styles(
frame,
&[
("position", "static"),
("borderTopWidth", "1px"),
("borderRightWidth", "1px"),
("borderBottomWidth", "1px"),
("borderLeftWidth", "1px"),
("borderTopColor", "rgb(220, 220, 220)"),
("borderRightColor", "rgb(220, 220, 220)"),
("borderBottomColor", "rgb(220, 220, 220)"),
("borderLeftColor", "rgb(220, 220, 220)"),
("outlineWidth", "0px"),
("backgroundColor", "rgb(250, 250, 250)"),
("paddingTop", "0px"),
("paddingRight", "0px"),
("paddingBottom", "0px"),
("paddingLeft", "0px"),
("fontSize", "15px"),
],
);
let table = d.add(Some(frame), "table");
d.set_rect(table, 0.0, 0.0, 860.0, 300.0);
for (i, (x, y, w)) in [(0.0, 0.0, 430.0), (430.0, 0.0, 430.0), (0.0, 260.0, 430.0)]
.into_iter()
.enumerate()
{
let cell = d.add(Some(table), "td");
d.add_text(cell, "Wednesday afternoon");
d.set_rect(cell, x, y, w, 20.0);
// Cell padding: 10px down, 16px across, which is where the text is.
d.set_text_lines(cell, &[(x + 16.0, y + 10.0, w - 32.0, 17.0)]);
let _ = i;
}
assert_eq!(check_element_quality_dom(&d, frame, &BrowserConfig::default()), vec![]);
// A cell that really does put its text on the frame line is charged.
let tight = d.add(Some(table), "td");
d.add_text(tight, "Wednesday afternoon");
d.set_rect(tight, 0.0, 140.0, 860.0, 20.0);
d.set_text_lines(tight, &[(1.0, 140.0, 858.0, 17.0)]);
let hits = check_element_quality_dom(&d, frame, &BrowserConfig::default());
assert_eq!(hits.len(), 1, "{hits:?}");
assert_eq!(
hits[0].snippet,
"<div> \"table-frame\": children flush against border+bg on right/left (no inset)"
);
// The same frame at 390px, where the table keeps its min-width and the
// frame hides what does not fit: the right side is clipped, not snug,
// and that is `clipped-overflow-container`'s business (REN-403).
d.set_rect(table, 0.0, 0.0, 1400.0, 300.0);
let hits = check_element_quality_dom(&d, frame, &BrowserConfig::default());
assert_eq!(hits.len(), 1, "{hits:?}");
assert_eq!(
hits[0].snippet,
"<div> \"table-frame\": children flush against border+bg on left (no inset)"
);
}
#[test]
fn typography_rules() {
let mut d = FakeDom::new();
+83 -12
View File
@@ -5,8 +5,10 @@
//! `checkRepeatedContainerTextDOM`) against the [`Dom`] probe. The pure
//! gates live in `checks::rules` / `checks::text_rules`.
use super::dom::{tag_lower, Dom, ElId, ElStyle};
use super::dom::{matches_or_false, tag_lower, Dom, ElId, ElStyle};
use super::driver::DesignSystemConfig;
use super::element_checks::{class_selector, is_rendered_for_browser_rule};
use super::{BrowserFinding, ElFinding};
use crate::checks::measures::resolve_length_px;
use crate::checks::rules::{check_kicker_above_heading, KickerCandidate, RuleHit};
use crate::checks::text_rules::{
@@ -99,6 +101,17 @@ fn strip_edge_quotes_slice(text: &str, n: usize) -> String {
/// JS: checks.mjs#collectKickerCandidates(document, getComputedStyle, resolveLengthPx || 0)
pub fn collect_kicker_candidates(dom: &dyn Dom) -> Vec<KickerCandidate> {
collect_kicker_candidates_with_elements(dom)
.into_iter()
.map(|(_, c)| c)
.collect()
}
/// The same walk, each candidate paired with the eyebrow element it came
/// from. The finding is about that element and belongs on it: reported
/// against the page it named `body`, and a charged row has to have something
/// to point at (REN-406).
pub fn collect_kicker_candidates_with_elements(dom: &dyn Dom) -> Vec<(ElId, KickerCandidate)> {
let mut candidates = Vec::new();
for heading in dom
.query_all(None, "h1, h2, h3, h4, [role=\"heading\"]")
@@ -164,18 +177,60 @@ pub fn collect_kicker_candidates(dom: &dyn Dom) -> Vec<KickerCandidate> {
if heading_tag == "h1" && heading_font_size >= 48.0 && kicker_letter_spacing >= 1.6 {
continue;
}
candidates.push(KickerCandidate {
heading_tag,
heading_text: strip_edge_quotes_slice(&heading_text, 60),
kicker_text: slice_utf16_prefix(&kicker_text, 40),
});
candidates.push((
kicker,
KickerCandidate {
heading_tag,
heading_text: strip_edge_quotes_slice(&heading_text, 60),
kicker_text: slice_utf16_prefix(&kicker_text, 40),
},
));
}
candidates
}
/// JS: checks.mjs#checkKickerAboveHeadingDOM()
pub fn check_kicker_above_heading_dom(dom: &dyn Dom) -> Vec<RuleHit> {
check_kicker_above_heading(&collect_kicker_candidates(dom))
///
/// Two things the page-level version could not do. The finding lands on the
/// eyebrow it is about rather than on `body`. And an eyebrow the repository's
/// own design document names — `.eyebrow`, written into DESIGN.md as the one
/// place caps are allowed — is that repository's vocabulary, not slop: a
/// pattern the author's contract declares by name is a component with rules,
/// and charging it reviews the design system instead of the change (REN-406).
pub fn check_kicker_above_heading_dom(
dom: &dyn Dom,
design_system: Option<&DesignSystemConfig>,
) -> Vec<ElFinding> {
let pairs: Vec<(ElId, KickerCandidate)> = collect_kicker_candidates_with_elements(dom)
.into_iter()
.filter(|(el, _)| !is_declared_component(dom, *el, design_system))
.collect();
let (els, candidates): (Vec<ElId>, Vec<KickerCandidate>) = pairs.into_iter().unzip();
check_kicker_above_heading(&candidates)
.into_iter()
.zip(els)
.map(|(hit, el)| ElFinding {
el: Some(el),
finding: BrowserFinding::new(hit.id, hit.snippet),
})
.collect()
}
/// Whether the repository's design document declares this element by name.
///
/// The selectors come from the DESIGN.md the review already parses, through
/// the same design-system config the colour and radius rules read.
pub fn is_declared_component(
dom: &dyn Dom,
el: ElId,
design_system: Option<&DesignSystemConfig>,
) -> bool {
let Some(ds) = design_system else {
return false;
};
ds.declared_selectors
.iter()
.any(|sel| matches_or_false(dom, el, sel))
}
/// JS: checks.mjs#collectNumberedSectionLabelCandidates(document, ...)
@@ -447,13 +502,29 @@ mod tests {
let h = d.add(Some(sec), "h2");
d.add_text(h, "Everything you need");
d.set_style(h, "fontSize", "32px");
let hits = check_kicker_above_heading_dom(&d);
let hits = check_kicker_above_heading_dom(&d, None);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "kicker-above-heading");
assert_eq!(hits[0].finding.type_, "kicker-above-heading");
assert_eq!(
hits[0].snippet,
hits[0].finding.detail,
"kicker \"Features\" above h2 \"Everything you need\""
);
// The finding names the eyebrow, not the page (REN-406).
assert_eq!(hits[0].el, Some(kicker));
// An eyebrow the repository's DESIGN.md declares by name stands down.
d.add_selector(kicker, ".eyebrow");
let ds = DesignSystemConfig {
declared_selectors: vec![".eyebrow".to_string()],
..Default::default()
};
assert!(check_kicker_above_heading_dom(&d, Some(&ds)).is_empty());
// A selector the document does not name leaves it charged.
let other = DesignSystemConfig {
declared_selectors: vec![".kicker".to_string()],
..Default::default()
};
assert_eq!(check_kicker_above_heading_dom(&d, Some(&other)).len(), 1);
// A card context (heading inside <article> that also contains the
// kicker) stands down.
let art = d.add(Some(body), "article");
@@ -463,7 +534,7 @@ mod tests {
let h2 = d.add(Some(art), "h3");
d.add_text(h2, "Card heading");
d.set_style(h2, "fontSize", "24px");
assert_eq!(check_kicker_above_heading_dom(&d).len(), 1);
assert_eq!(check_kicker_above_heading_dom(&d, None).len(), 1);
}
#[test]
+40 -5
View File
@@ -4,8 +4,8 @@
//! `undefined` / `null` distinctions the source relies on.
use crate::color::{
color_to_hex, composite_color_over, contrast_ratio, get_hue, has_chroma, is_neutral_color,
relative_luminance, Rgba,
color_to_hex, composite_color_over, contrast_ratio, get_hue, has_chroma, is_gray_ink,
is_neutral_color, relative_luminance, Rgba,
};
use crate::constants::{
BORDER_SAFE_TAGS, GENERIC_FONTS, KNOWN_SERIF_FONTS, SAFE_TAGS, WCAG_LARGE_BOLD_TEXT_PX,
@@ -231,9 +231,12 @@ fn contrast_findings(opts: &ColorOpts, text_color: &Rgba) -> Vec<RuleHit> {
}
};
let mut findings = Vec::new();
let text_lum = relative_luminance(text_color);
let is_gray = !has_chroma(Some(text_color), Some(20.0)) && text_lum > 0.05 && text_lum < 0.85;
if is_gray && bgs.iter().all(|b| has_chroma(Some(b), Some(40.0))) {
// Gray is low chroma at whatever lightness the ink sits at, and the
// surface is a colour when it has chroma of its own. The old pair of
// tests read relative luminance as if it were lightness, which made every
// off-white under 0.85 gray and charged an off-white nav on a teal
// masthead three times over (REN-404).
if is_gray_ink(text_color) && bgs.iter().all(|b| has_chroma(Some(b), Some(40.0))) {
let bg_label = match opts.effective_bg {
Some(bg) => color_to_hex(Some(&bg)),
None => format!(
@@ -1093,6 +1096,38 @@ mod tests {
);
}
/// REN-404. The bench's masthead: `#e8edf2` nav links on `#123a36`. The
/// ink is an off-white with a cool tint, not gray, and the pairing clears
/// contrast; the old test called everything under 0.85 relative luminance
/// gray and charged it three times over on a page with nothing wrong.
#[test]
fn off_white_on_a_colour_is_not_gray_ink() {
let ink = |hex_r: f64, hex_g: f64, hex_b: f64| {
check_colors(&ColorOpts {
tag: "p".to_string(),
font_size: 15.0,
font_weight: 400.0,
has_direct_text: true,
text_color: Some(Rgba::new(hex_r, hex_g, hex_b, 1.0)),
effective_bg: Some(Rgba::new(18.0, 58.0, 54.0, 1.0)),
..Default::default()
})
.into_iter()
.map(|h| h.id)
.collect::<Vec<_>>()
};
// #e8edf2 on #123a36.
assert_eq!(ink(232.0, 237.0, 242.0), Vec::<String>::new());
// White, the other neutral ink a coloured surface carries.
assert_eq!(ink(255.0, 255.0, 255.0), Vec::<String>::new());
// #8a8f8c: the muddy middle, still charged, and the contrast check
// beside it is untouched.
assert_eq!(
ink(138.0, 143.0, 140.0),
vec!["gray-on-color".to_string(), "low-contrast".to_string()]
);
}
#[test]
fn placeholder_colors_ignore_host_class_heuristics() {
let opts = ColorOpts {
+261 -2
View File
@@ -25,6 +25,7 @@ use crate::jsp;
use crate::util::{exists, js_string, re, read_json, read_text, ANY, WS};
const DESIGN_NAMES: &[&str] = &["DESIGN.md", "Design.md", "design.md"];
const FALLBACK_DIRS: &[&str] = &[".agents/context", "docs"];
const PROJECT_ROOT_MARKERS: &[&str] = &[".git", "package.json", ".impeccable"];
const COLOR_CHANNEL_TOLERANCE: f64 = 6.0;
@@ -37,6 +38,58 @@ pub const STATIC_DESIGN_SKIP_TAGS: &[&str] = &[
"head", "title", "meta", "link", "style", "script", "noscript", "template", "source",
];
re!(DESIGN_BACKTICKED, "`([^`\n]{1,80})`".to_string());
re!(
DESIGN_CLASS_SELECTOR,
"^\\.[A-Za-z_][A-Za-z0-9_-]*$".to_string()
);
// A design document's prose, cut where one statement stops and the next
// starts: punctuation, a line break, and the phrases that turn a sentence
// around. `instead of` / `rather than` open a clause about what the document
// is steering *away* from; the reversals (`outside`, `except`, ...) open one
// about what it is steering *toward*, which is what lets "no ALL CAPS outside
// the `.eyebrow` class" declare `.eyebrow`.
re!(
DESIGN_CLAUSE_SPLIT,
r"(?i)[.!?;:,()\[\]\n]|\u{2014}|\u{2013}|\binstead of\b|\brather than\b|\bas opposed to\b|\boutside\b|\bexcept\b|\bother than\b|\bunless\b|\bbesides\b|\bapart from\b|\bbeyond\b".to_string()
);
re!(
DESIGN_NEGATING_BOUNDARY,
r"(?i)^(?:instead of|rather than|as opposed to)$".to_string()
);
// A directive: it condemns what comes after it, and nothing before it.
// "Use `.kicker` and never `.tagline`" sanctions the first and forbids the
// second, and a rule that read the whole clause would lose both.
re!(
DESIGN_DIRECTIVE_NEGATIVE,
r"(?i)\b(?:no|not|never|nor|none|avoid\w*|don'?t|do not|doesn'?t|does not|drop|remove\w*|stop|skip)\b".to_string()
);
// A state: it describes whatever its clause is about, wherever in the clause
// the name sits. "`.card-old` is deprecated" names the class first.
re!(
DESIGN_STATE_NEGATIVE,
r"(?i)\b(?:deprecat\w*|obsolete|legacy|forbidden|banned|disallow\w*|discourag\w*|retired|unsupported|wrong|bad|anti-?pattern\w*|no longer|not allowed|not permitted|not supported|not used)\b".to_string()
);
// Headings that introduce a section of counter-examples.
re!(
DESIGN_NEGATIVE_HEADING,
r"(?i)\b(?:don'?ts?|do not|avoid|never|not to|anti-?patterns?|deprecat\w*|forbidden|banned|legacy|obsolete|retired|unsupported|removed|discourag\w*|disallow\w*|mistakes?|wrong|bad)\b".to_string()
);
// A heading that names both sides — "Do and Don't", "Dos and Don'ts",
// "Do / Do not" — introduces a section of both, so the subsections under it
// say which is which and the heading itself condemns nothing. The two sides
// have to be *joined* by something that pairs them: "Don't do this" and "What
// we don't do" also put a `do` beside a `don't`, and they mean only the one
// thing.
re!(DESIGN_BOTH_SIDES_HEADING, {
// One joiner or several: "Do's, and Don'ts" and "Do and/or Don't" pair
// the two sides with a comma plus a conjunction and with a conjunction
// plus a slash.
let joiner = format!(r"(?:{WS}*(?:and|or|&|/|\||\+|,|vs\.?|versus)){{1,4}}{WS}*", WS = WS);
let affirmative = r"\bdo'?s?\b";
let negative = r"\b(?:do ?n[o']?ts?|do not)\b";
format!("(?i)(?:{affirmative}{joiner}{negative}|{negative}{joiner}{affirmative})")
});
re!(
FONT_SIZE_LITERAL_RE,
format!("^-?[{D}.]+(?:px|rem)$", D = "0-9")
@@ -164,6 +217,129 @@ re!(LEADING_WS_RE, format!("^{WS}*"));
/// JS: design-system.mjs#parseFrontmatter. `None` when there is no
/// `---` block; otherwise the parsed object (possibly empty).
/// Class selectors the design document names as its own, in the order it
/// names them.
///
/// A design document writes a component in backticks — "No ALL CAPS outside
/// the `.eyebrow` class" — and that is the repository declaring a pattern by
/// name. A rule that fires on one of those is reviewing the design system
/// rather than the change, so the browser rules read this list and stand
/// down (REN-406). Only a plain class selector counts: a backticked file
/// name, property or hex is not a component.
///
/// Not every class a document names is a class it sanctions. A document also
/// writes down what it does *not* want — "Avoid `.eyebrow`", "`.card-old` is
/// deprecated", a "Don't" section of counter-examples — and exempting those
/// would silence exactly the misuse the document was written to forbid
/// (REN-406 follow-up). So each occurrence is read in the document's own
/// structure: the heading chain above it, and the clause it sits in. A class
/// condemned anywhere in the document is declared nowhere — a document that
/// says "deprecated" about a class has said enough.
pub fn declared_component_selectors(design_md: &str) -> Vec<String> {
// The prose is cut into clauses on punctuation, and a code span is full
// of punctuation that is not prose: `.btn-primary` carries a full stop,
// `rgb(0, 0, 0)` a pair of brackets and a comma. Masking every span to a
// run of letters of the same byte length keeps every offset where it was.
let masked = mask_code_spans(design_md);
let mut declared: Vec<String> = Vec::new();
let mut condemned: Vec<String> = Vec::new();
for cap in DESIGN_BACKTICKED.captures_iter(design_md) {
let span = cap.get(0).expect("whole match");
let token = js::trim(cap.get(1).map(|m| m.as_str()).unwrap_or(""));
if !DESIGN_CLASS_SELECTOR.is_match(token) {
continue;
}
let token = token.to_string();
let list = if design_condemns(&masked, span.start(), span.end()) {
&mut condemned
} else {
&mut declared
};
if !list.contains(&token) {
list.push(token);
}
if declared.len() + condemned.len() >= 128 {
break;
}
}
declared.retain(|t| !condemned.contains(t));
declared.truncate(64);
declared
}
/// Every code span replaced by a run of `a` of the same byte length, so the
/// prose around it can be scanned for sentence structure without a class
/// selector's own dot ending a sentence. ASCII in, same length out, so byte
/// offsets into the original still address the same characters.
fn mask_code_spans(design_md: &str) -> String {
let mut masked = design_md.as_bytes().to_vec();
for span in DESIGN_BACKTICKED.find_iter(design_md) {
for b in &mut masked[span.start()..span.end()] {
*b = b'a';
}
}
String::from_utf8(masked).unwrap_or_else(|_| design_md.to_string())
}
/// Whether the document speaks against the code span at `[start, end)`:
/// either it sits under a heading that introduces counter-examples, or its
/// own clause carries a word that condemns what the clause names.
fn design_condemns(masked: &str, start: usize, end: usize) -> bool {
under_negative_heading(masked, start) || clause_condemns(masked, start, end)
}
/// The heading chain above `start`, by level: a subsection of a "Don't"
/// section is still inside it.
fn under_negative_heading(masked: &str, start: usize) -> bool {
let mut chain: Vec<(usize, &str)> = Vec::new();
for line in masked[..start].split('\n') {
let line = line.trim_start();
let level = line.bytes().take_while(|b| *b == b'#').count();
if level == 0 || level > 6 {
continue;
}
let text = &line[level..];
if !text.is_empty() && !text.starts_with(' ') {
continue;
}
chain.retain(|(l, _)| *l < level);
chain.push((level, text));
}
chain.iter().any(|(_, text)| {
// "Dos and Don'ts" heads a section of both, and the subsections
// under it are what say which is which.
DESIGN_NEGATIVE_HEADING.is_match(text) && !DESIGN_BOTH_SIDES_HEADING.is_match(text)
})
}
/// The clause the span sits in — from the boundary before it to the boundary
/// after it — and whether that clause condemns what it names.
///
/// Where the negative word sits decides what it governs. A *state* ("`.x` is
/// deprecated") describes whatever the clause is about, so it condemns the
/// class wherever in the clause the name appears. A *directive* ("never use
/// `.x`") condemns what follows it and nothing before it, which is what keeps
/// "Use `.kicker` and never `.tagline`" from losing `.kicker`. A clause
/// opened by "instead of" or "rather than" is condemned by the boundary
/// itself, whatever words follow.
fn clause_condemns(masked: &str, start: usize, end: usize) -> bool {
let mut clause_start = 0usize;
let mut opened_by_negation = false;
for boundary in DESIGN_CLAUSE_SPLIT.find_iter(&masked[..start]) {
clause_start = boundary.end();
opened_by_negation = DESIGN_NEGATING_BOUNDARY.is_match(js::trim(boundary.as_str()));
}
if opened_by_negation {
return true;
}
let clause_end = DESIGN_CLAUSE_SPLIT
.find(&masked[end..])
.map(|m| end + m.start())
.unwrap_or(masked.len());
DESIGN_STATE_NEGATIVE.is_match(&masked[clause_start..clause_end])
|| DESIGN_DIRECTIVE_NEGATIVE.is_match(&masked[clause_start..start])
}
pub fn parse_frontmatter(md: &str) -> Option<Map<String, Value>> {
let lines: Vec<&str> = CRLF_RE.split(md).collect();
if js::trim(lines.first().copied().unwrap_or("")) != "---" {
@@ -534,6 +710,10 @@ pub struct AllowedFontSize {
#[derive(Debug, Clone, PartialEq, Default)]
pub struct DesignSystem {
pub present: bool,
/// Class selectors the document names as its own (REN-406). Filled where
/// the markdown itself is at hand; the allowlists come from frontmatter
/// and sidecar, this comes from the prose.
pub declared_selectors: Vec<String>,
pub source_path: Option<String>,
pub sidecar_path: Option<String>,
pub md_newer_than_json: bool,
@@ -877,13 +1057,15 @@ pub fn load_design_system_for_cwd(cwd: &str) -> Option<DesignSystem> {
let sidecar = sidecar_path.as_deref().and_then(read_json);
let sidecar_stat = sidecar_path.as_deref().and_then(mtime_ms);
let md_newer = matches!((md_stat, sidecar_stat), (Some(m), Some(s)) if m > s + 1000.0);
Some(normalize_design_system(
let mut ds = normalize_design_system(
Some(&frontmatter),
sidecar.as_ref(),
Some(&md.path),
sidecar_path.as_deref(),
md_newer,
))
);
ds.declared_selectors = declared_component_selectors(&text);
Some(ds)
}
/// JS `designSystemStartDir(targetPath, cwd)`.
@@ -1934,6 +2116,81 @@ fn finding_ignore_or_value_only(item: &Finding) -> String {
mod tests {
use super::*;
/// REN-406: the halfday design document's one rule about caps.
#[test]
fn declared_component_selectors_reads_the_document() {
let md = "# Halfday design system\n\n- Plain British English, sentence case everywhere.\n No Title Case, no ALL CAPS outside the `.eyebrow` class.\n- `styles/tokens.css` is the only file with a raw colour, `--ink-900` or `#0f172a`.\n- **Button.** Four kinds and no more: `.btn-primary`, `.btn-secondary`,\n `.btn-ghost`, `.btn-danger`. And `.btn-primary` again.\n";
assert_eq!(
declared_component_selectors(md),
vec![
".eyebrow",
".btn-primary",
".btn-secondary",
".btn-ghost",
".btn-danger"
]
);
assert!(declared_component_selectors("nothing to declare").is_empty());
}
/// A document also writes down what it does not want, and exempting those
/// classes silences exactly the misuse the document forbids.
#[test]
fn a_negative_example_declares_nothing() {
// Condemned in its own clause, by several spellings.
let md = "- Avoid `.eyebrow`; it shouts.\n- `.card-old` is deprecated.\n - Use `.kicker` instead of `.eyebrow-legacy`.\n - Every section label is a `.kicker`, not a `.tagline`.\n";
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
// A section of counter-examples, and its subsections with it.
let md = "## Components\n\n- The label above a heading is `.kicker`.\n\n ## Don't\n\n### Labels\n\n- `.eyebrow` anywhere.\n";
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
// Condemned once is condemned: a class the document calls deprecated
// is not rescued by a list that also names it.
let md = "- Buttons: `.btn-primary`, `.btn-old`.\n- `.btn-old` is deprecated.\n";
assert_eq!(declared_component_selectors(md), vec![".btn-primary"]);
// A directive governs what follows it, not the whole clause: a
// sentence that sanctions one class and forbids another says both.
let md = "- Use `.kicker` and never `.tagline`.\n\
- Every label is a `.kicker`, not a `.tagline`.\n";
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
// Headings that retire a set, in the words a document uses for it.
let md = "## Retired components\n\n- `.tagline`\n\n\
## Unsupported patterns\n\n- `.marquee-row`\n\n\
## Components\n\n- `.kicker`\n";
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
// A heading that names both sides heads a section of both, and its
// subsections are what say which is which.
let md = "## Dos and Don'ts\n\n### Do\n\n- Label a section with `.kicker`.\n\n\
### Don't\n\n- Reach for `.eyebrow`.\n";
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
// Compound separators pair the two sides just as well.
for heading in ["Do's, and Don'ts", "Do and/or Don't", "Don'ts / Dos"] {
let md = format!("## {heading}\n\n### Do\n\n- Use `.kicker`.\n");
assert_eq!(declared_component_selectors(&md), vec![".kicker"], "{heading}");
}
// A heading that only puts a `do` beside a `don't` is not a section
// of both, and still condemns what it names.
let md = "## Don't do this\n\n- `.eyebrow` above a heading.\n\n\
## What we don't do\n\n- `.tagline`\n\n\
## Components\n\n- `.kicker`\n";
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
// What the negative words govern is their own clause. "No ALL CAPS
// outside the `.eyebrow` class" declares `.eyebrow`, and "four kinds
// and no more" declares all four.
let md = "- No Title Case, no ALL CAPS outside the `.eyebrow` class.\n - **Button.** Four kinds and no more: `.btn-primary` (one per\n surface), `.btn-secondary`, `.btn-ghost`, `.btn-danger`.\n";
assert_eq!(
declared_component_selectors(md),
vec![".eyebrow", ".btn-primary", ".btn-secondary", ".btn-ghost", ".btn-danger"]
);
}
// ── #570 monorepo DESIGN.md inheritance ─────────────────────────────────
// Mirrors tests/detect-cli-design-monorepo.test.mjs (public repo main,
// 47e41195 + 5d7c1cce + e975bec4 + 91f2c7b4) at the findDesignRoot level.
@@ -2269,3 +2526,5 @@ mod tests {
assert_eq!(js_string(&parse_scalar("007")), "7");
}
}
+92
View File
@@ -168,6 +168,98 @@ pub trait Dom {
/// of every non-blank direct text node (rects narrower/shorter than 1px
/// dropped); `None` when there is none.
fn direct_text_rect(&self, el: ElId) -> Option<Rect>;
/// The rows the element's rendered text occupies: one rect per line box,
/// top to bottom. `None` when this DOM cannot say where the lines are.
///
/// This is how a rule reads a line rather than the box that holds it, and
/// a line here is the whole line the reader sees. `getClientRects()` on a
/// text node gives a rect per line box, but a line box is routinely split
/// across several text nodes — an inline `<strong>` in the middle of a
/// sentence, a framework marker, an HTML comment — so the rects are
/// collected over the element's whole rendered text (descendants
/// included, which is the text `text_content` counts) and the ones that
/// share a row are merged back into the one line they came from. Without
/// that merge each fragment is a "line" and one wrapped sentence is
/// charged as several.
///
/// `None` is the honest answer from a DOM that only kept the union of
/// those rects (a page snapshot captured before the lines were recorded).
/// A caller stands down there; it never divides a union by a line height
/// and calls the pieces lines, because the union of a long first line and
/// a short tail says nothing about either.
fn text_line_rects(&self, _el: ElId) -> Option<Vec<Rect>> {
None
}
}
/// The rects of one element's rendered text, merged into the lines they
/// rendered on.
///
/// Two rects are the same line when they share a row *and* run on from each
/// other. Sharing a row is a vertical band overlapping the band the row
/// started with by more than half the shorter height — that is what makes an
/// inline `<strong>`, a superscript and the text around them one line.
/// Running on is a horizontal gap no wider than the row's own line box: the
/// fragments of a wrapped line are contiguous, while two columns of text that
/// happen to sit on the same rows are separated by a gutter, and unioning
/// those would invent a page-wide line neither column ever rendered. An
/// inline image wider than the leading splits its line in two by the same
/// test, which understates a line rather than overstating it — the direction
/// this rule should err in.
///
/// Rects arrive in whatever order a DOM walked the text (an element's own
/// text and its descendants' are interleaved on the page but not in the
/// walk), so they are sorted top then left first and each rect joins the
/// newest row still level with it.
pub fn merge_text_rects_into_lines(rects: Vec<Rect>) -> Vec<Rect> {
let mut rects: Vec<Rect> = rects
.into_iter()
.filter(|r| r.width > 0.0 && r.height > 0.0 && r.all_finite())
.collect();
rects.sort_by(|a, b| {
a.top
.partial_cmp(&b.top)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.left.partial_cmp(&b.left).unwrap_or(std::cmp::Ordering::Equal))
});
let mut lines: Vec<Rect> = Vec::new();
// The band of the rect each row started with. Membership is tested
// against that rather than against the row as it grows, so an
// inline-block taller than the leading does not swallow the line beneath.
let mut bands: Vec<(f64, f64)> = Vec::new();
for r in rects {
let mut joined = false;
for i in (0..lines.len()).rev() {
let (band_top, band_bottom) = bands[i];
// Sorted by top: once a row sits entirely above this rect, every
// row before it does too.
if band_bottom <= r.top {
break;
}
let overlap = band_bottom.min(r.bottom) - band_top.max(r.top);
let shorter = (band_bottom - band_top).min(r.height);
if shorter <= 0.0 || overlap <= shorter / 2.0 {
continue;
}
let line = lines[i];
let gap = (r.left - line.right).max(line.left - r.right);
if gap > band_bottom - band_top {
continue;
}
let left = line.left.min(r.left);
let top = line.top.min(r.top);
let right = line.right.max(r.right);
let bottom = line.bottom.max(r.bottom);
lines[i] = Rect::from_xywh(left, top, right - left, bottom - top);
joined = true;
break;
}
if !joined {
bands.push((r.top, r.bottom));
lines.push(r);
}
}
lines
}
// ── shared helpers over the trait ─────────────────────────────────────────
+32
View File
@@ -35,6 +35,9 @@ pub struct FakeEl {
pub hidden: bool,
pub check_visibility: Option<bool>,
pub direct_text_rect: Option<Rect>,
/// The rects of the element's rendered text. `None` is a DOM that cannot
/// say where the lines are, the way a snapshot without them cannot.
pub text_line_rects: Option<Vec<Rect>>,
/// Selectors (exact strings) this element matches beyond `*` and its tag.
pub selectors: Vec<String>,
/// `id` IDL property override (`None` = "not a string", falls back to attr).
@@ -158,6 +161,31 @@ impl FakeDom {
self.el_mut(id).rect = Rect::from_xywh(x, y, w, h);
self
}
/// The union rect of `id`'s direct text, and nothing about its lines:
/// a DOM that measured the text once, the way a page snapshot captured
/// before the lines were recorded did.
pub fn set_text_rect(&mut self, id: ElId, x: f64, y: f64, w: f64, h: f64) -> &mut Self {
self.el_mut(id).direct_text_rect = Some(Rect::from_xywh(x, y, w, h));
self
}
/// The rects of `id`'s rendered text. The union is derived from them, so a
/// test declares what rendered and nothing else. Fragments that share a
/// row merge into one line on the way out, exactly as a live page's do.
pub fn set_text_lines(&mut self, id: ElId, lines: &[(f64, f64, f64, f64)]) -> &mut Self {
let rects: Vec<Rect> = lines
.iter()
.map(|&(x, y, w, h)| Rect::from_xywh(x, y, w, h))
.collect();
if !rects.is_empty() {
let left = rects.iter().map(|r| r.left).fold(f64::INFINITY, f64::min);
let top = rects.iter().map(|r| r.top).fold(f64::INFINITY, f64::min);
let right = rects.iter().map(|r| r.right).fold(f64::NEG_INFINITY, f64::max);
let bottom = rects.iter().map(|r| r.bottom).fold(f64::NEG_INFINITY, f64::max);
self.el_mut(id).direct_text_rect = Some(Rect::from_xywh(left, top, right - left, bottom - top));
}
self.el_mut(id).text_line_rects = Some(rects);
self
}
pub fn add_text(&mut self, id: ElId, text: &str) -> &mut Self {
self.el_mut(id)
.child_nodes
@@ -503,4 +531,8 @@ impl Dom for FakeDom {
fn direct_text_rect(&self, el: ElId) -> Option<Rect> {
self.els[el as usize].direct_text_rect
}
fn text_line_rects(&self, el: ElId) -> Option<Vec<Rect>> {
let rects = self.els[el as usize].text_line_rects.clone()?;
Some(super::dom::merge_text_rects_into_lines(rects))
}
}
+79
View File
@@ -273,6 +273,16 @@ pub struct SnapNode {
/// `getDirectTextRect` as `[x, y, width, height]`.
#[serde(rename = "d", default)]
pub direct_text_rect: Option<[f64; 4]>,
/// The client rects of the element's OWN direct text, unmerged, each
/// `[x, y, width, height]` — `direct_text_rect` before it was merged into
/// a union. Own and not the subtree's: an element's rendered lines are
/// assembled from these across its descendants
/// ([`SnapshotDom::text_line_rects`]), so a line that rendered is
/// recorded once rather than once per ancestor. Empty is "no rendered
/// text", not "not recorded" — [`Snapshot::text_lines`] is what says a
/// capture recorded them at all.
#[serde(rename = "dl", default, skip_serializing_if = "Vec::is_empty")]
pub text_rects: Vec<[f64; 4]>,
#[serde(rename = "e", default)]
pub content_editable: bool,
#[serde(rename = "h", default)]
@@ -374,6 +384,12 @@ pub struct Snapshot {
pub body: Option<u32>,
#[serde(rename = "bodyInnerText", default)]
pub body_inner_text: Option<String>,
/// Whether this capture recorded the rects of each element's rendered
/// text (`SnapNode::text_rects`). False in captures older than that
/// change, where the union in `d` is all there is: a rule that needs the
/// lines stands down rather than inventing them from the union.
#[serde(rename = "textLines", default)]
pub text_lines: bool,
#[serde(default)]
pub hits: Vec<HitTest>,
/// Derived on load: column index per style property name.
@@ -911,6 +927,29 @@ impl Dom for SnapshotDom {
fn direct_text_rect(&self, el: ElId) -> Option<Rect> {
self.snap.node(el).direct_text_rect.as_ref().map(rect4)
}
/// Assembled from the per-element rects the capture recorded: the
/// element's own, then each descendant's, in document order, merged into
/// the lines they rendered as.
///
/// `None` on a capture that never recorded them. The union in `d` is not
/// an answer here: a paragraph with one long line and a short tail has
/// the same union as one with two even lines, so dividing it by a line
/// height would invent widths that nothing on the page rendered.
fn text_line_rects(&self, el: ElId) -> Option<Vec<Rect>> {
if !self.snap.text_lines {
return None;
}
fn walk(snap: &Snapshot, el: ElId, out: &mut Vec<Rect>) {
let node = snap.node(el);
out.extend(node.text_rects.iter().map(rect4));
for child in &node.children {
walk(snap, *child, out);
}
}
let mut rects = Vec::new();
walk(&self.snap, el, &mut rects);
Some(super::dom::merge_text_rects_into_lines(rects))
}
}
/// `undefined` read into a wasm f64 is NaN (`offsetWidth` on an SVG
@@ -1087,6 +1126,46 @@ mod tests {
assert!(!d.has_needs());
}
/// A capture that recorded the text rects hands over the lines; one that
/// did not says so, and the caller stands down rather than reading lines
/// out of a union.
#[test]
fn text_lines_come_from_the_capture_or_not_at_all() {
const OLD: &str = r#"{
"v": 1, "documentElement": 1, "body": 2,
"els": [
{"t":"HTML","c":[2]},
{"t":"BODY","p":1,"c":[3]},
{"t":"P","p":2,"c":["hello"],"d":[0,100,1000,43]}
]
}"#;
assert_eq!(snap(OLD).text_line_rects(3), None);
// Each element records only its own text rects; an element's lines
// are assembled from its own plus its descendants'. Here the `<p>`
// wraps once and an inline `<b>` sits in the middle of its first
// line, so the first line arrives in three pieces from two elements.
const NEW: &str = r#"{
"v": 1, "textLines": true, "documentElement": 1, "body": 2,
"els": [
{"t":"HTML","c":[2]},
{"t":"BODY","p":1,"c":[3]},
{"t":"P","p":2,"c":["hello ",4," there"],"d":[0,100,1000,43],
"dl":[[0,100,600,19],[700,100,300,19],[0,124,120,19]]},
{"t":"B","p":3,"c":["bold"],"d":[600,100,100,19],"dl":[[600,100,100,19]]}
]
}"#;
let lines = snap(NEW).text_line_rects(3).expect("lines");
assert_eq!(lines.len(), 2);
assert_eq!((lines[0].left, lines[0].width), (0.0, 1000.0));
assert_eq!((lines[1].top, lines[1].width), (124.0, 120.0));
// The `<b>` on its own is the one line it rendered.
assert_eq!(snap(NEW).text_line_rects(4).expect("lines").len(), 1);
// An element the capture found no rendered text under is not
// "unknown" — it is an element with no lines.
assert_eq!(snap(NEW).text_line_rects(1).map(|l| l.len()), Some(2));
}
#[test]
fn css_escape_matches_spec() {
assert_eq!(css_escape("foo"), "foo");
+34
View File
@@ -320,6 +320,40 @@ pub fn has_chroma(c: Option<&Rgba>, threshold: Option<f64>) -> bool {
(math_max3(c.r, c.g, c.b) - math_min3(c.r, c.g, c.b)) >= threshold
}
/// A colour's lightness and saturation, HSL, both 0..1.
///
/// Saturation is chroma measured the same way at every lightness: the spread
/// between the channels over the widest spread a colour of that lightness
/// could have. The raw spread cannot say the same thing, because it shrinks
/// towards white and towards black — `#e8edf2` spreads 10 of 255 and is a
/// quarter of the way to fully saturated at its lightness, which is why it
/// reads as a cool off-white and not as gray (REN-404).
pub fn lightness_saturation(c: &Rgba) -> (f64, f64) {
let max = math_max3(c.r, c.g, c.b) / 255.0;
let min = math_min3(c.r, c.g, c.b) / 255.0;
let l = (max + min) / 2.0;
let d = max - min;
let s = if d <= 0.0 || l <= 0.0 || l >= 1.0 {
0.0
} else {
d / (1.0 - (2.0 * l - 1.0).abs())
};
(l, s)
}
/// Whether a colour reads as gray ink.
///
/// Two things at once: little chroma for the lightness it sits at, and a
/// lightness that is neither of the two neutral inks a coloured surface is
/// meant to carry. Near-white and near-black on a colour are deliberate; the
/// muddy middle is what this names. Relative luminance is not lightness and
/// cannot stand in for it: `#e8edf2` measures 0.84 there, under the old 0.85
/// ceiling, and 0.93 as lightness, which is where the eye puts it.
pub fn is_gray_ink(c: &Rgba) -> bool {
let (l, s) = lightness_saturation(c);
s < 0.2 && l > 0.2 && l < 0.85
}
/// JS `getHue(c)`.
pub fn get_hue(c: Option<&Rgba>) -> f64 {
let Some(c) = c else { return 0.0 };
+3 -3
View File
@@ -86,7 +86,7 @@ pub static ANTIPATTERNS: &[Antipattern] = &[
scopes: None,
severity: None,
name: "AI color palette",
description: "Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.",
description: "Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.",
skill_section: Some("Color & Contrast"),
skill_guideline: Some("AI color palette"),
},
@@ -416,7 +416,7 @@ pub static ANTIPATTERNS: &[Antipattern] = &[
scopes: Some(&["type", "layout"]),
severity: None,
name: "Line length too long",
description: "Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.",
description: "Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line, so it is measured on the lines that rendered and charged when more than one of them runs long. Add a max-width (65ch to 75ch) to text containers.",
skill_section: Some("Layout & Space"),
skill_guideline: Some("wrap beyond ~80 characters"),
},
@@ -426,7 +426,7 @@ pub static ANTIPATTERNS: &[Antipattern] = &[
scopes: Some(&["layout"]),
severity: None,
name: "Cramped padding",
description: "Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.",
description: "Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.",
skill_section: Some("Layout & Space"),
skill_guideline: Some("inside bordered or colored containers"),
},
+3 -3
View File
@@ -33,7 +33,7 @@
"id": "ai-color-palette",
"name": "AI color palette",
"category": "slop",
"description": "Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette."
"description": "Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette."
},
{
"id": "cream-palette",
@@ -231,13 +231,13 @@
"id": "line-length",
"name": "Line length too long",
"category": "quality",
"description": "Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers."
"description": "Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line, so it is measured on the lines that rendered and charged when more than one of them runs long. Add a max-width (65ch to 75ch) to text containers."
},
{
"id": "cramped-padding",
"name": "Cramped padding",
"category": "quality",
"description": "Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers."
"description": "Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers."
},
{
"id": "body-text-viewport-edge",
File diff suppressed because one or more lines are too long
+14 -1
View File
@@ -5,7 +5,9 @@
//! (`u32::MAX`, or a first element of `u32::MAX` in arrays) because the probe
//! catches the DOM's SyntaxError and cannot throw across the boundary.
use impeccable_core::browser::dom::{Dom, ElId, KeyframeFrame, Rect, SelectorError};
use impeccable_core::browser::dom::{
merge_text_rects_into_lines, Dom, ElId, KeyframeFrame, Rect, SelectorError,
};
use std::cell::RefCell;
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
@@ -58,6 +60,7 @@ extern "C" {
fn offset_height(el: u32) -> f64;
fn check_visibility(el: u32) -> i32;
fn direct_text_rect(el: u32) -> Vec<f64>;
fn text_rects(el: u32) -> Vec<f64>;
}
fn opt(id: u32) -> Option<ElId> {
@@ -340,4 +343,14 @@ impl Dom for JsDom {
Some(to_rect(&v))
}
}
/// A live page can always say where its lines are. The probe flattens the
/// rects of every text node under the element into one array of eights, in
/// the order `rect` uses (a tail shorter than a rect is ignored), and the
/// fragments that share a row are merged back into the line they came
/// from here.
fn text_line_rects(&self, el: ElId) -> Option<Vec<Rect>> {
Some(merge_text_rects_into_lines(
text_rects(el).chunks_exact(8).map(to_rect).collect(),
))
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
{
"stdout": "[\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"<div> \\\"pass-split-container\\\": children flush against border on all sides (no inset)\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-hidden clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-clip clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-negative clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-right clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-shadow-utility clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overlay-surface clips a positioned child\"\n }\n]\n",
"stdout": "[\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"<div> \\\"pass-split-container\\\": children flush against border on all sides (no inset)\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-hidden clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-clip clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-negative clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overflow-right clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-shadow-utility clips a positioned child\"\n },\n {\n \"antipattern\": \"clipped-overflow-container\",\n \"name\": \"Positioned child clipped by overflow container\",\n \"description\": \"A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\",\n \"line\": 0,\n \"snippet\": \"div.box.flag-overlay-surface clips a positioned child\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
{
"stdout": "[\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"background-clip: text + gradient\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"background-clip: text + gradient\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"bg-clip-text + bg-gradient (Tailwind)\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet gradient (Tailwind)\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet accent colors detected\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"background-clip: text + gradient\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"bg-clip-text + bg-gradient (Tailwind)\"\n }\n]\n",
"stdout": "[\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"background-clip: text + gradient\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"background-clip: text + gradient\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"bg-clip-text + bg-gradient (Tailwind)\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet gradient (Tailwind)\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet accent colors detected\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"background-clip: text + gradient\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\",\n \"line\": 0,\n \"snippet\": \"bg-clip-text + bg-gradient (Tailwind)\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
@@ -1,5 +1,5 @@
{
"stdout": "[\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/edge-flush-cards.html\",\n \"line\": 0,\n \"snippet\": \"<div> \\\"scroller\\\": children flush against bg on all sides (no inset)\"\n },\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/edge-flush-cards.html\",\n \"line\": 0,\n \"snippet\": \"<div> \\\"scroller\\\": children flush against bg on all sides (no inset)\"\n },\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/edge-flush-cards.html\",\n \"line\": 0,\n \"snippet\": \"<div> \\\"scroller\\\": children flush against bg on all sides (no inset)\"\n }\n]\n",
"stdout": "[\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/edge-flush-cards.html\",\n \"line\": 0,\n \"snippet\": \"<div> \\\"scroller\\\": children flush against bg on all sides (no inset)\"\n },\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/edge-flush-cards.html\",\n \"line\": 0,\n \"snippet\": \"<div> \\\"scroller\\\": children flush against bg on all sides (no inset)\"\n },\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/edge-flush-cards.html\",\n \"line\": 0,\n \"snippet\": \"<div> \\\"scroller\\\": children flush against bg on all sides (no inset)\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
{
"stdout": "[\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/heading-rhythm.html\",\n \"line\": 0,\n \"snippet\": \"<div> \\\"pass-band\\\": children flush against bg on right/left (no inset)\"\n }\n]\n",
"stdout": "[\n {\n \"antipattern\": \"cramped-padding\",\n \"name\": \"Cramped padding\",\n \"description\": \"Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/heading-rhythm.html\",\n \"line\": 0,\n \"snippet\": \"<div> \\\"pass-band\\\": children flush against bg on right/left (no inset)\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
@@ -1,5 +1,5 @@
{
"stdout": "[\n {\n \"antipattern\": \"icon-tile-stack\",\n \"name\": \"Icon tile stacked above heading\",\n \"description\": \"A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"80x80px icon tile above h3 \\\"Lightning Fast\\\"\"\n },\n {\n \"antipattern\": \"icon-tile-stack\",\n \"name\": \"Icon tile stacked above heading\",\n \"description\": \"A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"64x64px icon tile above h3 \\\"Secure Storage\\\"\"\n },\n {\n \"antipattern\": \"icon-tile-stack\",\n \"name\": \"Icon tile stacked above heading\",\n \"description\": \"A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"72x72px icon tile above h3 \\\"Easy Setup\\\"\"\n },\n {\n \"antipattern\": \"icon-tile-stack\",\n \"name\": \"Icon tile stacked above heading\",\n \"description\": \"A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"96x96px icon tile above h3 \\\"Powerful Analytics\\\"\"\n },\n {\n \"antipattern\": \"icon-tile-stack\",\n \"name\": \"Icon tile stacked above heading\",\n \"description\": \"A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"48x48px icon tile above h3 \\\"Emoji Inline Icon\\\"\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet accent colors detected\"\n },\n {\n \"antipattern\": \"marketing-buzzword\",\n \"name\": \"Marketing buzzword\",\n \"description\": \"Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"1 buzzword phrase: \\\"ure Storage Enterprise-grade security fo\\\"\"\n }\n]\n",
"stdout": "[\n {\n \"antipattern\": \"icon-tile-stack\",\n \"name\": \"Icon tile stacked above heading\",\n \"description\": \"A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"80x80px icon tile above h3 \\\"Lightning Fast\\\"\"\n },\n {\n \"antipattern\": \"icon-tile-stack\",\n \"name\": \"Icon tile stacked above heading\",\n \"description\": \"A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"64x64px icon tile above h3 \\\"Secure Storage\\\"\"\n },\n {\n \"antipattern\": \"icon-tile-stack\",\n \"name\": \"Icon tile stacked above heading\",\n \"description\": \"A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"72x72px icon tile above h3 \\\"Easy Setup\\\"\"\n },\n {\n \"antipattern\": \"icon-tile-stack\",\n \"name\": \"Icon tile stacked above heading\",\n \"description\": \"A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"96x96px icon tile above h3 \\\"Powerful Analytics\\\"\"\n },\n {\n \"antipattern\": \"icon-tile-stack\",\n \"name\": \"Icon tile stacked above heading\",\n \"description\": \"A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"48x48px icon tile above h3 \\\"Emoji Inline Icon\\\"\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"Purple/violet accent colors detected\"\n },\n {\n \"antipattern\": \"marketing-buzzword\",\n \"name\": \"Marketing buzzword\",\n \"description\": \"Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\",\n \"line\": 0,\n \"snippet\": \"1 buzzword phrase: \\\"ure Storage Enterprise-grade security fo\\\"\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
@@ -1,5 +1,5 @@
{
"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/jsx-should-flag.jsx\",\n \"line\": 8,\n \"snippet\": \"border-l-4\"\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/jsx-should-flag.jsx\",\n \"line\": 35,\n \"snippet\": \"borderLeft: '4px solid\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/jsx-should-flag.jsx\",\n \"line\": 22,\n \"snippet\": \"bg-clip-text + bg-gradient\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/jsx-should-flag.jsx\",\n \"line\": 10,\n \"snippet\": \"text-purple-500 on heading\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/jsx-should-flag.jsx\",\n \"line\": 22,\n \"snippet\": \"from-purple-400 gradient\"\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/jsx-should-flag.jsx\",\n \"line\": 50,\n \"snippet\": \"animate-bounce (Tailwind)\"\n },\n {\n \"antipattern\": \"layout-transition\",\n \"name\": \"Layout property animation\",\n \"description\": \"Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/jsx-should-flag.jsx\",\n \"line\": 51,\n \"snippet\": \"transition: width\"\n }\n]\n",
"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/jsx-should-flag.jsx\",\n \"line\": 8,\n \"snippet\": \"border-l-4\"\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/jsx-should-flag.jsx\",\n \"line\": 35,\n \"snippet\": \"borderLeft: '4px solid\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/jsx-should-flag.jsx\",\n \"line\": 22,\n \"snippet\": \"bg-clip-text + bg-gradient\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/jsx-should-flag.jsx\",\n \"line\": 10,\n \"snippet\": \"text-purple-500 on heading\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/jsx-should-flag.jsx\",\n \"line\": 22,\n \"snippet\": \"from-purple-400 gradient\"\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/jsx-should-flag.jsx\",\n \"line\": 50,\n \"snippet\": \"animate-bounce (Tailwind)\"\n },\n {\n \"antipattern\": \"layout-transition\",\n \"name\": \"Layout property animation\",\n \"description\": \"Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/jsx-should-flag.jsx\",\n \"line\": 51,\n \"snippet\": \"transition: width\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
@@ -1,5 +1,5 @@
{
"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/multifile/Card.tsx\",\n \"line\": 11,\n \"snippet\": \"border-l-4\",\n \"importedBy\": [\n \"App.tsx\"\n ]\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/multifile/Card.tsx\",\n \"line\": 12,\n \"snippet\": \"text-purple-500 on heading\",\n \"importedBy\": [\n \"App.tsx\"\n ]\n },\n {\n \"antipattern\": \"overused-font\",\n \"name\": \"Overused font\",\n \"description\": \"Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/multifile/styles.css\",\n \"line\": 3,\n \"snippet\": \"font-family: 'Inter\",\n \"importedBy\": [\n \"App.tsx\"\n ]\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/multifile/styles.css\",\n \"line\": 8,\n \"snippet\": \"animation: bounce\",\n \"importedBy\": [\n \"App.tsx\"\n ]\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/multifile/theme.sass\",\n \"line\": 4,\n \"snippet\": \"border-left: 4px solid $primary\"\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/multifile/theme.scss\",\n \"line\": 4,\n \"snippet\": \"border-left: 4px solid $primary\"\n }\n]\n",
"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/multifile/Card.tsx\",\n \"line\": 11,\n \"snippet\": \"border-l-4\",\n \"importedBy\": [\n \"App.tsx\"\n ]\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/multifile/Card.tsx\",\n \"line\": 12,\n \"snippet\": \"text-purple-500 on heading\",\n \"importedBy\": [\n \"App.tsx\"\n ]\n },\n {\n \"antipattern\": \"overused-font\",\n \"name\": \"Overused font\",\n \"description\": \"Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/multifile/styles.css\",\n \"line\": 3,\n \"snippet\": \"font-family: 'Inter\",\n \"importedBy\": [\n \"App.tsx\"\n ]\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/multifile/styles.css\",\n \"line\": 8,\n \"snippet\": \"animation: bounce\",\n \"importedBy\": [\n \"App.tsx\"\n ]\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/multifile/theme.sass\",\n \"line\": 4,\n \"snippet\": \"border-left: 4px solid $primary\"\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/multifile/theme.scss\",\n \"line\": 4,\n \"snippet\": \"border-left: 4px solid $primary\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
{
"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/svelte-should-flag.svelte\",\n \"line\": 6,\n \"snippet\": \"border-r-4\"\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/svelte-should-flag.svelte\",\n \"line\": 17,\n \"snippet\": \"border-right: 4px solid #8b5cf6\"\n },\n {\n \"antipattern\": \"overused-font\",\n \"name\": \"Overused font\",\n \"description\": \"Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\",\n \"line\": 19,\n \"snippet\": \"font-family: 'Roboto\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\",\n \"line\": 7,\n \"snippet\": \"text-purple-500 on heading\"\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\",\n \"line\": 10,\n \"snippet\": \"animate-bounce (Tailwind)\"\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\",\n \"line\": 30,\n \"snippet\": \"animation: elastic\"\n },\n {\n \"antipattern\": \"layout-transition\",\n \"name\": \"Layout property animation\",\n \"description\": \"Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\",\n \"line\": 31,\n \"snippet\": \"transition: height\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\",\n \"line\": 26,\n \"snippet\": \"background-clip: text + gradient\"\n }\n]\n",
"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/svelte-should-flag.svelte\",\n \"line\": 6,\n \"snippet\": \"border-r-4\"\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/svelte-should-flag.svelte\",\n \"line\": 17,\n \"snippet\": \"border-right: 4px solid #8b5cf6\"\n },\n {\n \"antipattern\": \"overused-font\",\n \"name\": \"Overused font\",\n \"description\": \"Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\",\n \"line\": 19,\n \"snippet\": \"font-family: 'Roboto\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\",\n \"line\": 7,\n \"snippet\": \"text-purple-500 on heading\"\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\",\n \"line\": 10,\n \"snippet\": \"animate-bounce (Tailwind)\"\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\",\n \"line\": 30,\n \"snippet\": \"animation: elastic\"\n },\n {\n \"antipattern\": \"layout-transition\",\n \"name\": \"Layout property animation\",\n \"description\": \"Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\",\n \"line\": 31,\n \"snippet\": \"transition: height\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\",\n \"line\": 26,\n \"snippet\": \"background-clip: text + gradient\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
@@ -1,5 +1,5 @@
{
"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/vue-should-flag.vue\",\n \"line\": 2,\n \"snippet\": \"border-l-4\"\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/vue-should-flag.vue\",\n \"line\": 20,\n \"snippet\": \"border-left: 4px solid #6366f1\"\n },\n {\n \"antipattern\": \"overused-font\",\n \"name\": \"Overused font\",\n \"description\": \"Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\",\n \"line\": 22,\n \"snippet\": \"font-family: 'Inter\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\",\n \"line\": 3,\n \"snippet\": \"text-purple-600 on heading\"\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\",\n \"line\": 5,\n \"snippet\": \"animate-bounce (Tailwind)\"\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\",\n \"line\": 33,\n \"snippet\": \"animation: bounce\"\n },\n {\n \"antipattern\": \"layout-transition\",\n \"name\": \"Layout property animation\",\n \"description\": \"Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\",\n \"line\": 34,\n \"snippet\": \"transition: width\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\",\n \"line\": 28,\n \"snippet\": \"background-clip: text + gradient\"\n }\n]\n",
"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/vue-should-flag.vue\",\n \"line\": 2,\n \"snippet\": \"border-l-4\"\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/vue-should-flag.vue\",\n \"line\": 20,\n \"snippet\": \"border-left: 4px solid #6366f1\"\n },\n {\n \"antipattern\": \"overused-font\",\n \"name\": \"Overused font\",\n \"description\": \"Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\",\n \"line\": 22,\n \"snippet\": \"font-family: 'Inter\"\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\",\n \"line\": 3,\n \"snippet\": \"text-purple-600 on heading\"\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\",\n \"line\": 5,\n \"snippet\": \"animate-bounce (Tailwind)\"\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\",\n \"line\": 33,\n \"snippet\": \"animation: bounce\"\n },\n {\n \"antipattern\": \"layout-transition\",\n \"name\": \"Layout property animation\",\n \"description\": \"Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\",\n \"line\": 34,\n \"snippet\": \"transition: width\"\n },\n {\n \"antipattern\": \"gradient-text\",\n \"name\": \"Gradient text\",\n \"description\": \"Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\",\n \"line\": 28,\n \"snippet\": \"background-clip: text + gradient\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\n [cramped-padding] <div> \"pass-split-container\": children flush against border on all sides (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\n [clipped-overflow-container] div.box.flag-overflow-hidden clips a positioned child\n → A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\n [clipped-overflow-container] div.box.flag-overflow-clip clips a positioned child\n → A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\n [clipped-overflow-container] div.box.flag-overflow-negative clips a positioned child\n → A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\n [clipped-overflow-container] div.box.flag-overflow-right clips a positioned child\n → A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\n [clipped-overflow-container] div.box.flag-shadow-utility clips a positioned child\n → A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\n [clipped-overflow-container] div.box.flag-overlay-surface clips a positioned child\n → A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\n\n7 anti-patterns found.\n",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/clipped-overflow-container.html\n [cramped-padding] <div> \"pass-split-container\": children flush against border on all sides (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\n [clipped-overflow-container] div.box.flag-overflow-hidden clips a positioned child\n → A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\n [clipped-overflow-container] div.box.flag-overflow-clip clips a positioned child\n → A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\n [clipped-overflow-container] div.box.flag-overflow-negative clips a positioned child\n → A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\n [clipped-overflow-container] div.box.flag-overflow-right clips a positioned child\n → A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\n [clipped-overflow-container] div.box.flag-shadow-utility clips a positioned child\n → A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\n [clipped-overflow-container] div.box.flag-overlay-surface clips a positioned child\n → A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.\n\n7 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\n [gradient-text] background-clip: text + gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n [gradient-text] background-clip: text + gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n [gradient-text] bg-clip-text + bg-gradient (Tailwind)\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n [ai-color-palette] Purple/violet gradient (Tailwind)\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n [ai-color-palette] Purple/violet accent colors detected\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n [gradient-text] background-clip: text + gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n [gradient-text] bg-clip-text + bg-gradient (Tailwind)\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n\n7 anti-patterns found.\n",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/css-in-prose-should-flag.html\n [gradient-text] background-clip: text + gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n [gradient-text] background-clip: text + gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n [gradient-text] bg-clip-text + bg-gradient (Tailwind)\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n [ai-color-palette] Purple/violet gradient (Tailwind)\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n [ai-color-palette] Purple/violet accent colors detected\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n [gradient-text] background-clip: text + gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n [gradient-text] bg-clip-text + bg-gradient (Tailwind)\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n\n7 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/edge-flush-cards.html\n [cramped-padding] <div> \"scroller\": children flush against bg on all sides (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\n [cramped-padding] <div> \"scroller\": children flush against bg on all sides (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\n [cramped-padding] <div> \"scroller\": children flush against bg on all sides (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\n\n3 anti-patterns found.\n",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/edge-flush-cards.html\n [cramped-padding] <div> \"scroller\": children flush against bg on all sides (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\n [cramped-padding] <div> \"scroller\": children flush against bg on all sides (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\n [cramped-padding] <div> \"scroller\": children flush against bg on all sides (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\n\n3 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/flush-against-border.html\n [cramped-padding] <section> \"flag-frameworks\": children flush against border+bg on right/bottom/left (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\n [cramped-padding] <div> \"flag-card-borders\": children flush against border+bg on right/bottom/left (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\n [cramped-padding] <div> \"flag-bg-only\": children flush against bg on right/bottom/left (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\n [cramped-padding] <div> \"flag-outline-only\": children flush against outline on right/bottom/left (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\n [cramped-padding] <div> \"flag-asym-leftflush\": children flush against border+bg on left (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\n [cramped-padding] <section> \"pass-marquee-shell\": children flush against bg on all sides (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\n\n6 anti-patterns found.\n",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/flush-against-border.html\n [cramped-padding] <section> \"flag-frameworks\": children flush against border+bg on right/bottom/left (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\n [cramped-padding] <div> \"flag-card-borders\": children flush against border+bg on right/bottom/left (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\n [cramped-padding] <div> \"flag-bg-only\": children flush against bg on right/bottom/left (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\n [cramped-padding] <div> \"flag-outline-only\": children flush against outline on right/bottom/left (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\n [cramped-padding] <div> \"flag-asym-leftflush\": children flush against border+bg on left (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\n [cramped-padding] <section> \"pass-marquee-shell\": children flush against bg on all sides (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\n\n6 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "\nNext.js project detected (next.config.mjs).\nStart the dev server and scan via URL for best results:\n npx impeccable detect http://localhost:3000\n\n\n<REPO>/tests/fixtures/antipatterns/framework-next-tailwind/app/globals.css (imported by layout.tsx)\n line 19: [overused-font] font-family: 'Inter\n → Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\n\n<REPO>/tests/fixtures/antipatterns/framework-next-tailwind/app/page.tsx\n line 15: [gradient-text] bg-clip-text + bg-gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n line 41: [ai-color-palette] text-purple-500 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n line 15: [ai-color-palette] from-purple-400 gradient\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n\n<REPO>/tests/fixtures/antipatterns/framework-next-tailwind/components/FeatureCard.tsx (imported by page.tsx)\n line 9: [side-tab] border-l-4\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 11: [ai-color-palette] text-purple-600 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n line 10: [bounce-easing] animate-bounce (Tailwind)\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n\n<REPO>/tests/fixtures/antipatterns/framework-next-tailwind/components/PricingCard.tsx (imported by page.tsx)\n line 18: [gradient-text] bg-clip-text + bg-gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n line 18: [ai-color-palette] from-violet-500 gradient\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n line 30: [ai-color-palette] from-purple-500 gradient\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n\n10 anti-patterns found.\n",
"stderr": "\nNext.js project detected (next.config.mjs).\nStart the dev server and scan via URL for best results:\n npx impeccable detect http://localhost:3000\n\n\n<REPO>/tests/fixtures/antipatterns/framework-next-tailwind/app/globals.css (imported by layout.tsx)\n line 19: [overused-font] font-family: 'Inter\n → Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\n\n<REPO>/tests/fixtures/antipatterns/framework-next-tailwind/app/page.tsx\n line 15: [gradient-text] bg-clip-text + bg-gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n line 41: [ai-color-palette] text-purple-500 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n line 15: [ai-color-palette] from-purple-400 gradient\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n\n<REPO>/tests/fixtures/antipatterns/framework-next-tailwind/components/FeatureCard.tsx (imported by page.tsx)\n line 9: [side-tab] border-l-4\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 11: [ai-color-palette] text-purple-600 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n line 10: [bounce-easing] animate-bounce (Tailwind)\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n\n<REPO>/tests/fixtures/antipatterns/framework-next-tailwind/components/PricingCard.tsx (imported by page.tsx)\n line 18: [gradient-text] bg-clip-text + bg-gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n line 18: [ai-color-palette] from-violet-500 gradient\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n line 30: [ai-color-palette] from-purple-500 gradient\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n\n10 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/heading-rhythm.html\n [cramped-padding] <div> \"pass-band\": children flush against bg on right/left (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.\n\n1 anti-pattern found.\n",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/heading-rhythm.html\n [cramped-padding] <div> \"pass-band\": children flush against bg on right/left (no inset)\n → Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers.\n\n1 anti-pattern found.\n",
"exit": 2,
"signal": null,
"files": {}
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\n [icon-tile-stack] 80x80px icon tile above h3 \"Lightning Fast\"\n → A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\n [icon-tile-stack] 64x64px icon tile above h3 \"Secure Storage\"\n → A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\n [icon-tile-stack] 72x72px icon tile above h3 \"Easy Setup\"\n → A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\n [icon-tile-stack] 96x96px icon tile above h3 \"Powerful Analytics\"\n → A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\n [icon-tile-stack] 48x48px icon tile above h3 \"Emoji Inline Icon\"\n → A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\n [ai-color-palette] Purple/violet accent colors detected\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n [marketing-buzzword] 1 buzzword phrase: \"ure Storage Enterprise-grade security fo\"\n → Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.\n\n7 anti-patterns found.\n",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/icon-tile-stack.html\n [icon-tile-stack] 80x80px icon tile above h3 \"Lightning Fast\"\n → A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\n [icon-tile-stack] 64x64px icon tile above h3 \"Secure Storage\"\n → A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\n [icon-tile-stack] 72x72px icon tile above h3 \"Easy Setup\"\n → A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\n [icon-tile-stack] 96x96px icon tile above h3 \"Powerful Analytics\"\n → A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\n [icon-tile-stack] 48x48px icon tile above h3 \"Emoji Inline Icon\"\n → A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.\n [ai-color-palette] Purple/violet accent colors detected\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n [marketing-buzzword] 1 buzzword phrase: \"ure Storage Enterprise-grade security fo\"\n → Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.\n\n7 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/jsx-should-flag.jsx\n line 8: [side-tab] border-l-4\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 35: [side-tab] borderLeft: '4px solid\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 22: [gradient-text] bg-clip-text + bg-gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n line 10: [ai-color-palette] text-purple-500 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n line 22: [ai-color-palette] from-purple-400 gradient\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n line 50: [bounce-easing] animate-bounce (Tailwind)\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n line 51: [layout-transition] transition: width\n → Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.\n\n7 anti-patterns found.\n",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/jsx-should-flag.jsx\n line 8: [side-tab] border-l-4\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 35: [side-tab] borderLeft: '4px solid\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 22: [gradient-text] bg-clip-text + bg-gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n line 10: [ai-color-palette] text-purple-500 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n line 22: [ai-color-palette] from-purple-400 gradient\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n line 50: [bounce-easing] animate-bounce (Tailwind)\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n line 51: [layout-transition] transition: width\n → Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.\n\n7 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/multifile/Card.tsx (imported by App.tsx)\n line 11: [side-tab] border-l-4\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 12: [ai-color-palette] text-purple-500 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n\n<REPO>/tests/fixtures/antipatterns/multifile/styles.css (imported by App.tsx)\n line 3: [overused-font] font-family: 'Inter\n → Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\n line 8: [bounce-easing] animation: bounce\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n\n<REPO>/tests/fixtures/antipatterns/multifile/theme.sass\n line 4: [side-tab] border-left: 4px solid $primary\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\n<REPO>/tests/fixtures/antipatterns/multifile/theme.scss\n line 4: [side-tab] border-left: 4px solid $primary\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\n6 anti-patterns found.\n",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/multifile/Card.tsx (imported by App.tsx)\n line 11: [side-tab] border-l-4\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 12: [ai-color-palette] text-purple-500 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n\n<REPO>/tests/fixtures/antipatterns/multifile/styles.css (imported by App.tsx)\n line 3: [overused-font] font-family: 'Inter\n → Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\n line 8: [bounce-easing] animation: bounce\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n\n<REPO>/tests/fixtures/antipatterns/multifile/theme.sass\n line 4: [side-tab] border-left: 4px solid $primary\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\n<REPO>/tests/fixtures/antipatterns/multifile/theme.scss\n line 4: [side-tab] border-left: 4px solid $primary\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\n6 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\n line 6: [side-tab] border-r-4\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] border-right: 4px solid #8b5cf6\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 19: [overused-font] font-family: 'Roboto\n → Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\n line 7: [ai-color-palette] text-purple-500 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n line 10: [bounce-easing] animate-bounce (Tailwind)\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n line 30: [bounce-easing] animation: elastic\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n line 31: [layout-transition] transition: height\n → Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.\n line 26: [gradient-text] background-clip: text + gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n\n8 anti-patterns found.\n",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/svelte-should-flag.svelte\n line 6: [side-tab] border-r-4\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] border-right: 4px solid #8b5cf6\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 19: [overused-font] font-family: 'Roboto\n → Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\n line 7: [ai-color-palette] text-purple-500 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n line 10: [bounce-easing] animate-bounce (Tailwind)\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n line 30: [bounce-easing] animation: elastic\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n line 31: [layout-transition] transition: height\n → Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.\n line 26: [gradient-text] background-clip: text + gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n\n8 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\n line 2: [side-tab] border-l-4\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 20: [side-tab] border-left: 4px solid #6366f1\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 22: [overused-font] font-family: 'Inter\n → Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\n line 3: [ai-color-palette] text-purple-600 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n line 5: [bounce-easing] animate-bounce (Tailwind)\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n line 33: [bounce-easing] animation: bounce\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n line 34: [layout-transition] transition: width\n → Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.\n line 28: [gradient-text] background-clip: text + gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n\n8 anti-patterns found.\n",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/vue-should-flag.vue\n line 2: [side-tab] border-l-4\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 20: [side-tab] border-left: 4px solid #6366f1\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 22: [overused-font] font-family: 'Inter\n → Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\n line 3: [ai-color-palette] text-purple-600 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n line 5: [bounce-easing] animate-bounce (Tailwind)\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n line 33: [bounce-easing] animation: bounce\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n line 34: [layout-transition] transition: width\n → Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.\n line 28: [gradient-text] background-clip: text + gradient\n → Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.\n\n8 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
{
"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/multifile/Card.tsx\",\n \"line\": 11,\n \"snippet\": \"border-l-4\",\n \"importedBy\": [\n \"App.tsx\"\n ]\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/multifile/Card.tsx\",\n \"line\": 12,\n \"snippet\": \"text-purple-500 on heading\",\n \"importedBy\": [\n \"App.tsx\"\n ]\n },\n {\n \"antipattern\": \"overused-font\",\n \"name\": \"Overused font\",\n \"description\": \"Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/multifile/styles.css\",\n \"line\": 3,\n \"snippet\": \"font-family: 'Inter\",\n \"importedBy\": [\n \"App.tsx\"\n ]\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/multifile/styles.css\",\n \"line\": 8,\n \"snippet\": \"animation: bounce\",\n \"importedBy\": [\n \"App.tsx\"\n ]\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/multifile/theme.sass\",\n \"line\": 4,\n \"snippet\": \"border-left: 4px solid $primary\"\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/multifile/theme.scss\",\n \"line\": 4,\n \"snippet\": \"border-left: 4px solid $primary\"\n }\n]\n",
"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/multifile/Card.tsx\",\n \"line\": 11,\n \"snippet\": \"border-l-4\",\n \"importedBy\": [\n \"App.tsx\"\n ]\n },\n {\n \"antipattern\": \"ai-color-palette\",\n \"name\": \"AI color palette\",\n \"description\": \"Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/multifile/Card.tsx\",\n \"line\": 12,\n \"snippet\": \"text-purple-500 on heading\",\n \"importedBy\": [\n \"App.tsx\"\n ]\n },\n {\n \"antipattern\": \"overused-font\",\n \"name\": \"Overused font\",\n \"description\": \"Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/multifile/styles.css\",\n \"line\": 3,\n \"snippet\": \"font-family: 'Inter\",\n \"importedBy\": [\n \"App.tsx\"\n ]\n },\n {\n \"antipattern\": \"bounce-easing\",\n \"name\": \"Bounce or elastic easing\",\n \"description\": \"Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/multifile/styles.css\",\n \"line\": 8,\n \"snippet\": \"animation: bounce\",\n \"importedBy\": [\n \"App.tsx\"\n ]\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/multifile/theme.sass\",\n \"line\": 4,\n \"snippet\": \"border-left: 4px solid $primary\"\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/multifile/theme.scss\",\n \"line\": 4,\n \"snippet\": \"border-left: 4px solid $primary\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/multifile/Card.tsx (imported by App.tsx)\n line 11: [side-tab] border-l-4\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 12: [ai-color-palette] text-purple-500 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.\n\n<REPO>/tests/fixtures/antipatterns/multifile/styles.css (imported by App.tsx)\n line 3: [overused-font] font-family: 'Inter\n → Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\n line 8: [bounce-easing] animation: bounce\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n\n<REPO>/tests/fixtures/antipatterns/multifile/theme.sass\n line 4: [side-tab] border-left: 4px solid $primary\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\n<REPO>/tests/fixtures/antipatterns/multifile/theme.scss\n line 4: [side-tab] border-left: 4px solid $primary\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\n6 anti-patterns found.\n",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/multifile/Card.tsx (imported by App.tsx)\n line 11: [side-tab] border-l-4\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 12: [ai-color-palette] text-purple-500 on heading\n → Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette.\n\n<REPO>/tests/fixtures/antipatterns/multifile/styles.css (imported by App.tsx)\n line 3: [overused-font] font-family: 'Inter\n → Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\n line 8: [bounce-easing] animation: bounce\n → Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.\n\n<REPO>/tests/fixtures/antipatterns/multifile/theme.sass\n line 4: [side-tab] border-left: 4px solid $primary\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\n<REPO>/tests/fixtures/antipatterns/multifile/theme.scss\n line 4: [side-tab] border-left: 4px solid $primary\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\n6 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