mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-20 01:56:37 +03:00
detector: catch pseudo-element side stripes; add pulsing-dot rule
Two gaps surfaced by human eval review of real artifacts: 1. side-tab missed the pseudo-element variant. The accent stripe drawn as an absolutely-positioned ::before/::after (left/right: 0, top+bottom: 0 or height: 100%, narrow width, colored background) uses no border property at all, so neither the element-level border checks (pseudo elements never enter the static cascade or DOM walk) nor the border-left/right regexes could see it. New scanCssTextForPseudoStripe scans stylesheet text for that shape, mirroring the border rule's gates: >= 3px thick (<= 12px), chromatic fill (var()-resolved, neutral dividers skipped), full height against a side edge, with the blockquote/prose exemptions preserved. 2. New pulsing-dot rule (slop): small circular "live" indicator dots (<= 16px, border-radius >= 40% or pill values) bound to an infinite animation whose keyframes vary opacity, scale, or box-shadow — or pulse/blink/ping names when the keyframes aren't in the scanned text — plus the Tailwind animate-ping/pulse + rounded-full + tiny-size utility combo. Rotation-only keyframes (spinners) never flag, including when they hide behind a pulse-like name. Both scanners live in checkHtmlPatterns, so the static-html engine and the browser bundle share the same detection path. Browser/extension bundles regenerated; docs rule count bumped to 47. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
eeff485c20
commit
cfdb7d4c81
@@ -15,6 +15,8 @@ import {
|
||||
checkElementTextOverflowDOM,
|
||||
checkPageTypography,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
scanCssTextForPseudoStripe,
|
||||
scanCssTextForPulsingDot,
|
||||
} from '../cli/engine/rules/checks.mjs';
|
||||
|
||||
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
|
||||
@@ -982,6 +984,166 @@ describe('detectHtml — static HTML/CSS engine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side-tab as absolutely-positioned pseudo-element stripe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('side-tab — pseudo-element stripe variant', () => {
|
||||
test('fixture flags both stripe variants and nothing else', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'pseudo-stripe.html'));
|
||||
const stripes = f.filter(r => r.antipattern === 'side-tab');
|
||||
const snippets = stripes.map(r => r.snippet).join(' | ');
|
||||
expect(stripes).toHaveLength(2);
|
||||
expect(snippets).toContain('.card-stripe::before');
|
||||
expect(snippets).toContain('.row-stripe::after');
|
||||
});
|
||||
|
||||
test('detects ::before stripe with var() background resolved to chromatic', () => {
|
||||
const css = `
|
||||
:root { --accent: oklch(0.78 0.145 155); }
|
||||
.hero::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--accent); }
|
||||
`;
|
||||
const f = scanCssTextForPseudoStripe(css);
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].id).toBe('side-tab');
|
||||
expect(f[0].snippet).toContain('.hero::before');
|
||||
});
|
||||
|
||||
test('detects height:100% + right:0 variant', () => {
|
||||
const css = '.card::after { position: absolute; right: 0; top: 0; height: 100%; width: 4px; background: #3b82f6; }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('unresolvable custom-property color errs toward detection', () => {
|
||||
const css = '.card::before { position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--from-external-sheet); }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('skips neutral hairline divider', () => {
|
||||
const css = '.col::before { position: absolute; left: 0; top: 0; bottom: 0; width: 1px; background: rgba(0,0,0,0.08); }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips neutral 4px rail (chromatic gate)', () => {
|
||||
const css = '.timeline::before { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: rgb(209, 213, 219); }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips 2px stripe below width threshold', () => {
|
||||
const css = '.card::before { position: absolute; left: 0; top: 0; bottom: 0; width: 2px; background: #3b82f6; }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips blockquote pseudo decoration', () => {
|
||||
const css = 'blockquote::before { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: #d97706; }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips non-edge-anchored pseudo (toggle knob)', () => {
|
||||
const css = '.switch::before { position: absolute; left: 2px; top: 2px; width: 10px; height: 10px; background: #3b82f6; }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips full-overlay pseudo (inset: 0, no narrow width)', () => {
|
||||
const css = '.hero::after { position: absolute; inset: 0; background: #3b82f6; }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pulsing status dots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pulsing-dot', () => {
|
||||
test('fixture flags the four pulsing dots and none of the passes', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'pulsing-dot.html'));
|
||||
const dots = f.filter(r => r.antipattern === 'pulsing-dot');
|
||||
const snippets = dots.map(r => r.snippet).join(' | ');
|
||||
expect(dots).toHaveLength(4);
|
||||
expect(snippets).toContain('.live-dot');
|
||||
expect(snippets).toContain('.status .dot');
|
||||
expect(snippets).toContain('.beacon');
|
||||
expect(snippets).toContain('animate-ping');
|
||||
expect(snippets).not.toContain('spinner');
|
||||
expect(snippets).not.toContain('fake-pulse');
|
||||
expect(snippets).not.toContain('breathing-card');
|
||||
expect(snippets).not.toContain('square-badge');
|
||||
});
|
||||
|
||||
test('detects tiny circle with infinite opacity-pulse keyframes', () => {
|
||||
const css = `
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
`;
|
||||
const f = scanCssTextForPulsingDot(css);
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].id).toBe('pulsing-dot');
|
||||
});
|
||||
|
||||
test('detects box-shadow ripple keyframes', () => {
|
||||
const css = `
|
||||
.dot { width: 7px; height: 7px; border-radius: 999px; animation: ripple 1.8s linear infinite; }
|
||||
@keyframes ripple { 0% { box-shadow: 0 0 0 0 rgba(0,255,0,0.4); } 100% { box-shadow: 0 0 0 6px rgba(0,255,0,0); } }
|
||||
`;
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('accepts pulse-family names when keyframes are not in the scanned text', () => {
|
||||
const css = '.dot { width: 8px; height: 8px; border-radius: 50%; animation: blink 1.4s infinite; }';
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('rotation-only animations never flag (spinners)', () => {
|
||||
const css = `
|
||||
.spinner { width: 14px; height: 14px; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
`;
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rotation-only keyframes win over a pulse-like name', () => {
|
||||
const css = `
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse-ring 1s linear infinite; }
|
||||
@keyframes pulse-ring { to { transform: rotate(180deg); } }
|
||||
`;
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips large pulsing surfaces (not a dot)', () => {
|
||||
const css = `
|
||||
.card { width: 240px; height: 120px; border-radius: 16px; animation: pulse 3s infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
`;
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips finite pulse animations', () => {
|
||||
const css = `
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse 0.6s ease-out 3; }
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
`;
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips non-circular pulsing elements', () => {
|
||||
const css = `
|
||||
.badge { width: 12px; height: 12px; border-radius: 2px; animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
`;
|
||||
expect(scanCssTextForPulsingDot(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Tailwind animate-ping on tiny rounded-full element flags; large skeleton does not', () => {
|
||||
const html = `
|
||||
<span class="animate-ping w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
<div class="animate-pulse rounded-md w-48 h-6 bg-gray-200"></div>
|
||||
`;
|
||||
const f = scanCssTextForPulsingDot(html);
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].snippet).toContain('animate-ping');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ANTIPATTERNS registry
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Pseudo-element side stripe fixture</title>
|
||||
<style>
|
||||
:root {
|
||||
--ok: oklch(0.78 0.145 155);
|
||||
--line: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* FLAG: classic ::before accent stripe — absolute, full height, left edge,
|
||||
chromatic background resolved through a custom property. */
|
||||
.card-stripe { position: relative; border-radius: 12px; }
|
||||
.card-stripe::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 5px;
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
/* FLAG: right-edge variant using inset shorthand + literal color. */
|
||||
.row-stripe { position: relative; }
|
||||
.row-stripe::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 0 auto;
|
||||
width: 4px;
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* PASS: 1px neutral hairline divider (below width threshold + neutral). */
|
||||
.col + .col::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
/* PASS: neutral 4px rail (timeline spine) — chromatic gate rejects it. */
|
||||
.timeline::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 4px;
|
||||
background: rgb(210, 210, 214);
|
||||
}
|
||||
|
||||
/* PASS: blockquote decoration is exempt in prose contexts. */
|
||||
blockquote::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 4px;
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
/* PASS: toggle knob — offset from the edge, not a stripe. */
|
||||
.switch::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 2px; top: 2px;
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
/* PASS: wide decorative panel, not a stripe. */
|
||||
.panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 40px;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
/* PASS: horizontal underline accent (not full height, not edge-anchored
|
||||
vertically). */
|
||||
.heading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; bottom: 0;
|
||||
width: 6px; height: 3px;
|
||||
background: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card-stripe">Card with pseudo stripe</div>
|
||||
<div class="row-stripe">Row with right stripe</div>
|
||||
<div class="col">One</div><div class="col">Two</div>
|
||||
<div class="timeline">Timeline</div>
|
||||
<blockquote>Quoted text</blockquote>
|
||||
<span class="switch"></span>
|
||||
<div class="panel">Panel</div>
|
||||
<h2 class="heading">Heading</h2>
|
||||
</body>
|
||||
</html>
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Pulsing status dot fixture</title>
|
||||
<style>
|
||||
:root { --dot: 8px; }
|
||||
|
||||
/* FLAG: tiny circular live dot on an infinite opacity pulse. */
|
||||
.live-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
animation: pulse 2.4s ease-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* FLAG: box-shadow ripple variant, var-sized, pill radius. */
|
||||
.status .dot {
|
||||
width: var(--dot); height: var(--dot);
|
||||
border-radius: 999px;
|
||||
background: #ef4444;
|
||||
animation: ripple 1.8s linear infinite;
|
||||
}
|
||||
@keyframes ripple {
|
||||
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
100% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
/* FLAG: longhand animation-name + iteration-count. */
|
||||
.beacon {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
animation-name: pulse;
|
||||
animation-duration: 2s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
/* PASS: rotation-only spinner — same size class, must never flag. */
|
||||
.spinner {
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* PASS: named pulse but the local keyframes only rotate. */
|
||||
.fake-pulse {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
animation: pulse-ring 1s linear infinite;
|
||||
}
|
||||
@keyframes pulse-ring { to { transform: rotate(180deg); } }
|
||||
|
||||
/* PASS: large breathing card — not a dot. */
|
||||
.breathing-card {
|
||||
width: 240px; height: 120px;
|
||||
border-radius: 16px;
|
||||
animation: pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* PASS: finite attention pulse (no infinite). */
|
||||
.nudge {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
animation: pulse 0.6s ease-out 3;
|
||||
}
|
||||
|
||||
/* PASS: square badge, not circular. */
|
||||
.square-badge {
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 2px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<span class="live-dot"></span>
|
||||
<span class="status"><i class="dot"></i> Online</span>
|
||||
<span class="beacon"></span>
|
||||
<span class="spinner"></span>
|
||||
<span class="fake-pulse"></span>
|
||||
<div class="breathing-card">Card</div>
|
||||
<span class="nudge"></span>
|
||||
<span class="square-badge"></span>
|
||||
<!-- FLAG: Tailwind utility variant -->
|
||||
<span class="animate-ping w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
<!-- PASS: Tailwind pulse on a large skeleton block -->
|
||||
<div class="animate-pulse rounded-md w-48 h-6 bg-gray-200"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user