diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 3d948ad39..5266535c3 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -1863,9 +1863,11 @@ function collectMarqueeKeyframes(content) { // bound to a keyframe loop that travels a large horizontal distance. // Rotation/opacity animations never qualify (no X travel); JS-driven // carousels with user controls have no infinite CSS X-loop to match. -function scanCssTextForMarquee(content) { +// `content` is CSS-bearing text; `markup` (defaulting to the same string +// for single-corpus callers) is where the tag itself lives. +function scanCssTextForMarquee(content, markup = content) { const findings = []; - if (/ element' }); } const marqueeKeyframes = collectMarqueeKeyframes(content); @@ -2044,10 +2046,14 @@ function selectorHitsLandmark(content, selector, ranges) { // element sits inside a header/nav landmark is the hero liveness cliché // and is promoted to error severity; occurrences elsewhere keep the // registry default severity. -function scanCssTextForPulsingDot(content) { +// +// `content` is CSS-bearing text (rules and keyframes); `markup` — defaulting +// to the same string for single-corpus callers like the regex source +// engine — is where landmark ranges and Tailwind class attributes live. +function scanCssTextForPulsingDot(content, markup = content) { const customProps = collectCssCustomProps(content); const keyframes = collectPulseKeyframes(content); - const heroRanges = landmarkSourceRanges(content); + const heroRanges = landmarkSourceRanges(markup); const findings = []; const seen = new Set(); @@ -2096,7 +2102,7 @@ function scanCssTextForPulsingDot(content) { if (seen.has(selector)) continue; seen.add(selector); - const inLandmark = selectorHitsLandmark(content, selector, heroRanges); + const inLandmark = selectorHitsLandmark(markup, selector, heroRanges); findings.push({ id: 'pulsing-dot', snippet: `${selector} — ${w}x${h}px dot with infinite "${pulseName}" animation${inLandmark ? ' in header/nav' : ''}`, @@ -2106,10 +2112,11 @@ function scanCssTextForPulsingDot(content) { } // Tailwind utilities: animate-ping / animate-pulse on a tiny rounded-full - // element declared entirely in the class attribute. + // element declared entirely in the class attribute. Scanned in the markup + // corpus so the match index lines up with the landmark ranges. const classRe = /class\s*=\s*(?:"([^"]*)"|'([^']*)')/gi; let cm; - while ((cm = classRe.exec(content)) !== null) { + while ((cm = classRe.exec(markup)) !== null) { const cls = cm[1] || cm[2] || ''; const anim = cls.match(/\banimate-(ping|pulse)\b/); if (!anim) continue; @@ -2190,20 +2197,64 @@ function scanHtmlForShapeAssembledIllustration(html) { return findings; } +// Scoped scan corpora for the page-level pattern checks. CSS-property +// regexes run over the whole source string fire on documentation ABOUT +// css — `background-clip: text` prose,
 samples, HTML
+// comments — so the checks scan only the strings that actually style the
+// page:
+//   styleText — 
+        

Styled gradient heading

+

Body copy long enough to make this a real page for the scanners.

+ `, + }, async ({ file }) => { + const f = await detectHtml(file); + expect(f.some(r => r.antipattern === 'gradient-text' && /background-clip/.test(r.snippet))).toBe(true); + }); + }); + + test('gradient-text: prose and code samples about the pattern do not flag', async () => { + await withStaticFixture({ + 'index.html': `changelog +

Changelog

+

background-clip: text gradients stop tripping the contrast rules.

+
.hero { background: linear-gradient(135deg, #667eea, #764ba2); background-clip: text; }
+

Pair bg-clip-text with bg-gradient-to-r and both utilities together are the tell.

+ `, + }, async ({ file }) => { + const f = await detectHtml(file); + expect(f.filter(r => r.antipattern === 'gradient-text')).toHaveLength(0); + expect(f.filter(r => r.antipattern === 'ai-color-palette')).toHaveLength(0); + }); + }); + test('flattens @layer, resolves CSS variables and fallbacks, and skips unsupported selectors', async () => { await withStaticFixture({ 'index.html': ` diff --git a/tests/fixtures/antipatterns/css-in-prose-should-flag.html b/tests/fixtures/antipatterns/css-in-prose-should-flag.html new file mode 100644 index 000000000..af69dc8e3 --- /dev/null +++ b/tests/fixtures/antipatterns/css-in-prose-should-flag.html @@ -0,0 +1,35 @@ + + + + + +Real gradient-text styling + + + +

Ship Faster With Us

+ + +

Inline gradient heading

+ + +

Tailwind gradient heading

+ +

Body copy long enough to make this a real page for the scanners to read.

+ + diff --git a/tests/fixtures/antipatterns/css-in-prose-should-pass.html b/tests/fixtures/antipatterns/css-in-prose-should-pass.html new file mode 100644 index 000000000..96f5282a1 --- /dev/null +++ b/tests/fixtures/antipatterns/css-in-prose-should-pass.html @@ -0,0 +1,77 @@ + + + + + +Docs about CSS anti-patterns + + + +

Changelog

+ + +

Detector

+

background-clip: text gradients stop tripping the contrast rules when the glyph fill is transparent.

+ + +

What the rule catches

+
.hero-title {
+  background: linear-gradient(135deg, #667eea, #764ba2);
+  -webkit-background-clip: text;
+  background-clip: text;
+  color: #7c3aed;
+}
+ + +

The Tailwind form pairs bg-clip-text with bg-gradient-to-r on one element; the two utilities together are the tell.

+ + + + +

Pulsing dots

+
.status-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  animation: pulse 2s ease-in-out infinite;
+}
+ + +

Side stripes

+
.card::before {
+  content: "";
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  width: 4px;
+  background: #3b82f6;
+}
+ + +