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:
Paul Bakaus
2026-07-11 19:06:41 -07:00
co-authored by Claude Fable 5
parent eeff485c20
commit cfdb7d4c81
9 changed files with 898 additions and 7 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# Impeccable
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 46 deterministic detector rules for AI-generated frontend design.
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 47 deterministic detector rules for AI-generated frontend design.
> **Quick start:** From your project root, run `npx impeccable install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style).
@@ -13,7 +13,7 @@ Every model trained on the same SaaS templates. Skip the guidance and you get th
Impeccable adds:
- **One setup flow.** `/impeccable init` writes `PRODUCT.md` and offers `DESIGN.md`, so later commands know the audience, brand/product lane, voice, anti-references, colors, type, and components.
- **23 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more.
- **46 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key.
- **47 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key.
## What's Included
@@ -357,7 +357,7 @@ npx impeccable ignores add-file "src/legacy/**"
npx impeccable ignores add-value overused-font Inter --reason "Brand font"
```
The detector catches 46 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
The detector catches 47 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
+2 -2
View File
@@ -1,6 +1,6 @@
# Impeccable CLI
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 46 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 47 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
## Quick Start
@@ -56,7 +56,7 @@ npx impeccable detect --fast src/
**Quality**: tiny body text, cramped padding, long line lengths, small touch targets
46 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
47 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
## Exit Codes
+266
View File
@@ -206,6 +206,15 @@ const ANTIPATTERNS = [
skillSection: 'Motion',
skillGuideline: 'bounce or elastic easing',
},
{
id: 'pulsing-dot',
category: 'slop',
name: 'Pulsing status dot',
description:
'Small pulsing status dots simulate liveness decoratively. Reserve pulse animation for indicators tied to genuinely live, changing data; a static indicator with clear labeling is honest and calmer.',
skillSection: 'Motion',
skillGuideline: 'decorative pulsing status dot',
},
{
id: 'dark-glow',
category: 'slop',
@@ -1233,6 +1242,253 @@ function scanCssTextForGlow(content) {
return results;
}
// ---------------------------------------------------------------------------
// Text-level CSS rule-block scanners (pseudo-element stripes, pulsing dots)
// ---------------------------------------------------------------------------
// Iterate `selector { declarations }` pairs in raw CSS/HTML text. The block
// body excludes braces, so nested structures (@media, @keyframes) naturally
// yield their innermost rules with the innermost selector text. Callers
// create the regex locally — a shared /g instance is not re-entrant.
const CSS_RULE_BLOCK_SOURCE = String.raw`([^{};]+)\{([^{}]*)\}`;
// Parse a declaration block into a prop → value map (last declaration wins,
// approximating the cascade inside one block). Values keep their raw text
// with any !important suffix stripped.
function parseCssDeclBlock(block) {
const decls = new Map();
for (const part of String(block || '').split(';')) {
const idx = part.indexOf(':');
if (idx <= 0) continue;
const prop = part.slice(0, idx).trim().toLowerCase();
const value = part.slice(idx + 1).replace(/\s*!important\s*$/i, '').trim();
if (prop && value) decls.set(prop, value);
}
return decls;
}
function cssLengthToPx(value) {
const m = String(value || '').trim().match(/^(-?[\d.]+)(px|rem|em)$/i);
if (!m) return null;
const n = parseFloat(m[1]);
return m[2].toLowerCase() === 'px' ? n : n * 16;
}
function isZeroOffset(value) {
return value != null && /^-?0(?:px|%|rem|em)?$/.test(String(value).trim());
}
// Side-tab variant: the accent stripe drawn as an absolutely-positioned
// ::before/::after pseudo-element (narrow colored box hugging a vertical
// edge) instead of a border-left/right. The element-level border checks
// never see it — pseudo-elements aren't part of the DOM the cascade walks —
// so this scans stylesheet text directly, mirroring the border rule's
// gates: >= 3px thick, chromatic fill, full height against a side edge.
function scanCssTextForPseudoStripe(content) {
const customProps = collectCssCustomProps(content);
const findings = [];
const seen = new Set();
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
if (!/::?(?:before|after)\b/i.test(selector)) continue;
// Keep the border rule's prose exemptions (blockquote bars etc.).
if (/\b(?:blockquote|pre|code|nav|hr)\b/i.test(selector)) continue;
const decls = parseCssDeclBlock(m[2]);
const position = decls.get('position');
if (position !== 'absolute' && position !== 'fixed') continue;
const widthPx = cssLengthToPx(resolveVarRefs(
decls.get('width') || decls.get('inline-size') || '', customProps));
if (widthPx == null || widthPx < 3 || widthPx > 12) continue;
// Resolve edge offsets, letting an `inset` shorthand fill the gaps.
const offsets = {
top: decls.get('top'), right: decls.get('right'),
bottom: decls.get('bottom'), left: decls.get('left'),
};
const inset = decls.get('inset');
if (inset) {
const p = inset.split(/\s+/);
const [t, r, b, l] =
p.length === 1 ? [p[0], p[0], p[0], p[0]]
: p.length === 2 ? [p[0], p[1], p[0], p[1]]
: p.length === 3 ? [p[0], p[1], p[2], p[1]]
: p;
if (offsets.top == null) offsets.top = t;
if (offsets.right == null) offsets.right = r;
if (offsets.bottom == null) offsets.bottom = b;
if (offsets.left == null) offsets.left = l;
}
if (offsets.left == null) offsets.left = decls.get('inset-inline-start');
if (offsets.right == null) offsets.right = decls.get('inset-inline-end');
const heightValue = String(resolveVarRefs(
decls.get('height') || decls.get('block-size') || '', customProps)).trim();
const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom))
|| /^100(?:\.0*)?%$/.test(heightValue);
if (!fullHeight) continue;
const edge = isZeroOffset(offsets.left) ? 'left'
: isZeroOffset(offsets.right) ? 'right' : null;
if (!edge) continue;
// Chromatic fill only — a neutral hairline divider is not an accent
// stripe. Unresolvable colors err toward detection, matching the
// border rule's unknown-format default.
const bg = String(resolveVarRefs(
decls.get('background-color') || decls.get('background') || '', customProps)).trim();
if (!bg || /^(?:none|transparent|inherit|initial|unset|currentcolor)$/i.test(bg)) continue;
const colorToken = bg.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\([^)]*\)|#[0-9a-f]{3,8}\b/i);
const parsed = parseAnyColor(colorToken ? colorToken[0] : bg);
if (parsed) {
if ((parsed.a ?? 1) < 0.1) continue;
const spread = Math.max(parsed.r, parsed.g, parsed.b) - Math.min(parsed.r, parsed.g, parsed.b);
if (spread < 30) continue;
} else if (/^(?:white|black|gray|grey|silver)$/i.test(bg)) {
continue;
}
if (seen.has(selector)) continue;
seen.add(selector);
findings.push({
id: 'side-tab',
snippet: `${selector} — absolute ${widthPx}px pseudo-element stripe (${edge}: 0)`,
});
}
return findings;
}
// Collect @keyframes names and whether each one reads as a "pulse" —
// i.e. it varies opacity, scale, or box-shadow. Rotation-only keyframes
// (spinners) are explicitly not pulses.
function collectPulseKeyframes(content) {
const map = new Map();
const re = /@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/g;
let m;
while ((m = re.exec(content)) !== null) {
let depth = 1;
let i = re.lastIndex;
while (i < content.length && depth > 0) {
const ch = content.charCodeAt(i);
if (ch === 0x7b /* { */) depth++;
else if (ch === 0x7d /* } */) depth--;
i++;
}
const body = content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1));
const pulses = /\bopacity\s*:/i.test(body)
|| /\bbox-shadow\s*:/i.test(body)
|| /\btransform\s*:[^;{}]*\bscale/i.test(body);
if (!map.has(m[1]) || pulses) map.set(m[1], pulses);
re.lastIndex = i;
}
return map;
}
const ANIMATION_VALUE_KEYWORDS = new Set([
'ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear',
'infinite', 'alternate', 'alternate-reverse', 'normal', 'reverse',
'none', 'forwards', 'backwards', 'both', 'running', 'paused',
'step-start', 'step-end', 'inherit', 'initial', 'unset',
]);
// Extract animation names that run with iteration-count: infinite from a
// declaration block (shorthand layers or animation-name + iteration-count).
function infiniteAnimationNames(decls) {
const out = [];
const shorthand = decls.get('animation');
if (shorthand) {
for (const layer of shorthand.split(/,(?![^(]*\))/)) {
if (!/\binfinite\b/i.test(layer)) continue;
const name = layer.split(/\s+/).find(t =>
/^[a-zA-Z_-][\w-]*$/.test(t) && !ANIMATION_VALUE_KEYWORDS.has(t.toLowerCase()));
if (name) out.push(name);
}
}
const nameDecl = decls.get('animation-name');
if (nameDecl && /\binfinite\b/i.test(decls.get('animation-iteration-count') || '')) {
for (const raw of nameDecl.split(',')) {
const t = raw.trim();
if (t && t.toLowerCase() !== 'none') out.push(t);
}
}
return out;
}
function isRoundDotRadius(radiusValue, w, h) {
if (!radiusValue) return false;
const first = String(radiusValue).trim().split(/\s+/)[0];
const pct = first.match(/^([\d.]+)%$/);
if (pct) return parseFloat(pct[1]) >= 40;
const px = cssLengthToPx(first);
if (px == null) return false;
return px >= 999 || px >= 0.4 * Math.min(w, h);
}
// Small circular indicator bound to an infinite pulse animation — the
// decorative "live" dot. Gates: tiny (<= 16px square-ish), round
// (border-radius >= 40% or pill values), and an infinite animation whose
// keyframes vary opacity/scale/box-shadow (or a pulse/blink/ping name when
// the keyframes aren't in the scanned text). Rotation-only animations
// (spinners) never flag.
function scanCssTextForPulsingDot(content) {
const customProps = collectCssCustomProps(content);
const keyframes = collectPulseKeyframes(content);
const findings = [];
const seen = new Set();
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
const decls = parseCssDeclBlock(m[2]);
const names = infiniteAnimationNames(decls);
if (names.length === 0) continue;
const pulseName = names.find(n => {
const known = keyframes.get(n);
if (known != null) return known;
return /pulse|blink|ping/i.test(n);
});
if (!pulseName) continue;
const w = cssLengthToPx(resolveVarRefs(
decls.get('width') || decls.get('inline-size') || '', customProps));
const h = cssLengthToPx(resolveVarRefs(
decls.get('height') || decls.get('block-size') || '', customProps));
if (w == null || h == null || w < 2 || h < 2 || w > 16 || h > 16) continue;
const radius = resolveVarRefs(decls.get('border-radius') || '', customProps);
if (!isRoundDotRadius(radius, w, h)) continue;
if (seen.has(selector)) continue;
seen.add(selector);
findings.push({
id: 'pulsing-dot',
snippet: `${selector}${w}x${h}px dot with infinite "${pulseName}" animation`,
});
}
// Tailwind utilities: animate-ping / animate-pulse on a tiny rounded-full
// element declared entirely in the class attribute.
const classRe = /class\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
let cm;
while ((cm = classRe.exec(content)) !== null) {
const cls = cm[1] || cm[2] || '';
const anim = cls.match(/\banimate-(ping|pulse)\b/);
if (!anim) continue;
if (!/\brounded-full\b/.test(cls)) continue;
if (!/\b(?:w|h|size)-(?:1|1\.5|2|2\.5|3|3\.5|4)\b/.test(cls)) continue;
const key = `tw:${cls}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({
id: 'pulsing-dot',
snippet: `animate-${anim[1]} on tiny rounded-full element`,
});
}
return findings;
}
/**
* Regex-on-HTML checks shared between browser and Node page-level detection.
* These don't need DOM access, just the raw HTML string.
@@ -1266,6 +1522,13 @@ function checkHtmlPatterns(html) {
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
}
// --- Borders ---
// Side-tab accent stripe drawn as an absolutely-positioned pseudo-element
// (no border property involved, so the element-level border checks and
// the border-left regexes never see it).
findings.push(...scanCssTextForPseudoStripe(html));
// --- Layout ---
// Monotonous spacing
@@ -1341,6 +1604,9 @@ function checkHtmlPatterns(html) {
}
}
// Pulsing status dots (tiny circular elements on infinite pulse animations)
findings.push(...scanCssTextForPulsingDot(html));
// --- Dark glow / chromatic halo shadows ---
const glowHits = scanCssTextForGlow(html);
+9
View File
@@ -104,6 +104,15 @@ const ANTIPATTERNS = [
skillSection: 'Motion',
skillGuideline: 'bounce or elastic easing',
},
{
id: 'pulsing-dot',
category: 'slop',
name: 'Pulsing status dot',
description:
'Small pulsing status dots simulate liveness decoratively. Reserve pulse animation for indicators tied to genuinely live, changing data; a static indicator with clear labeling is honest and calmer.',
skillSection: 'Motion',
skillGuideline: 'decorative pulsing status dot',
},
{
id: 'dark-glow',
category: 'slop',
+259
View File
@@ -559,6 +559,253 @@ function scanCssTextForGlow(content) {
return results;
}
// ---------------------------------------------------------------------------
// Text-level CSS rule-block scanners (pseudo-element stripes, pulsing dots)
// ---------------------------------------------------------------------------
// Iterate `selector { declarations }` pairs in raw CSS/HTML text. The block
// body excludes braces, so nested structures (@media, @keyframes) naturally
// yield their innermost rules with the innermost selector text. Callers
// create the regex locally — a shared /g instance is not re-entrant.
const CSS_RULE_BLOCK_SOURCE = String.raw`([^{};]+)\{([^{}]*)\}`;
// Parse a declaration block into a prop → value map (last declaration wins,
// approximating the cascade inside one block). Values keep their raw text
// with any !important suffix stripped.
function parseCssDeclBlock(block) {
const decls = new Map();
for (const part of String(block || '').split(';')) {
const idx = part.indexOf(':');
if (idx <= 0) continue;
const prop = part.slice(0, idx).trim().toLowerCase();
const value = part.slice(idx + 1).replace(/\s*!important\s*$/i, '').trim();
if (prop && value) decls.set(prop, value);
}
return decls;
}
function cssLengthToPx(value) {
const m = String(value || '').trim().match(/^(-?[\d.]+)(px|rem|em)$/i);
if (!m) return null;
const n = parseFloat(m[1]);
return m[2].toLowerCase() === 'px' ? n : n * 16;
}
function isZeroOffset(value) {
return value != null && /^-?0(?:px|%|rem|em)?$/.test(String(value).trim());
}
// Side-tab variant: the accent stripe drawn as an absolutely-positioned
// ::before/::after pseudo-element (narrow colored box hugging a vertical
// edge) instead of a border-left/right. The element-level border checks
// never see it — pseudo-elements aren't part of the DOM the cascade walks —
// so this scans stylesheet text directly, mirroring the border rule's
// gates: >= 3px thick, chromatic fill, full height against a side edge.
function scanCssTextForPseudoStripe(content) {
const customProps = collectCssCustomProps(content);
const findings = [];
const seen = new Set();
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
if (!/::?(?:before|after)\b/i.test(selector)) continue;
// Keep the border rule's prose exemptions (blockquote bars etc.).
if (/\b(?:blockquote|pre|code|nav|hr)\b/i.test(selector)) continue;
const decls = parseCssDeclBlock(m[2]);
const position = decls.get('position');
if (position !== 'absolute' && position !== 'fixed') continue;
const widthPx = cssLengthToPx(resolveVarRefs(
decls.get('width') || decls.get('inline-size') || '', customProps));
if (widthPx == null || widthPx < 3 || widthPx > 12) continue;
// Resolve edge offsets, letting an `inset` shorthand fill the gaps.
const offsets = {
top: decls.get('top'), right: decls.get('right'),
bottom: decls.get('bottom'), left: decls.get('left'),
};
const inset = decls.get('inset');
if (inset) {
const p = inset.split(/\s+/);
const [t, r, b, l] =
p.length === 1 ? [p[0], p[0], p[0], p[0]]
: p.length === 2 ? [p[0], p[1], p[0], p[1]]
: p.length === 3 ? [p[0], p[1], p[2], p[1]]
: p;
if (offsets.top == null) offsets.top = t;
if (offsets.right == null) offsets.right = r;
if (offsets.bottom == null) offsets.bottom = b;
if (offsets.left == null) offsets.left = l;
}
if (offsets.left == null) offsets.left = decls.get('inset-inline-start');
if (offsets.right == null) offsets.right = decls.get('inset-inline-end');
const heightValue = String(resolveVarRefs(
decls.get('height') || decls.get('block-size') || '', customProps)).trim();
const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom))
|| /^100(?:\.0*)?%$/.test(heightValue);
if (!fullHeight) continue;
const edge = isZeroOffset(offsets.left) ? 'left'
: isZeroOffset(offsets.right) ? 'right' : null;
if (!edge) continue;
// Chromatic fill only — a neutral hairline divider is not an accent
// stripe. Unresolvable colors err toward detection, matching the
// border rule's unknown-format default.
const bg = String(resolveVarRefs(
decls.get('background-color') || decls.get('background') || '', customProps)).trim();
if (!bg || /^(?:none|transparent|inherit|initial|unset|currentcolor)$/i.test(bg)) continue;
const colorToken = bg.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\([^)]*\)|#[0-9a-f]{3,8}\b/i);
const parsed = parseAnyColor(colorToken ? colorToken[0] : bg);
if (parsed) {
if ((parsed.a ?? 1) < 0.1) continue;
const spread = Math.max(parsed.r, parsed.g, parsed.b) - Math.min(parsed.r, parsed.g, parsed.b);
if (spread < 30) continue;
} else if (/^(?:white|black|gray|grey|silver)$/i.test(bg)) {
continue;
}
if (seen.has(selector)) continue;
seen.add(selector);
findings.push({
id: 'side-tab',
snippet: `${selector} — absolute ${widthPx}px pseudo-element stripe (${edge}: 0)`,
});
}
return findings;
}
// Collect @keyframes names and whether each one reads as a "pulse" —
// i.e. it varies opacity, scale, or box-shadow. Rotation-only keyframes
// (spinners) are explicitly not pulses.
function collectPulseKeyframes(content) {
const map = new Map();
const re = /@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/g;
let m;
while ((m = re.exec(content)) !== null) {
let depth = 1;
let i = re.lastIndex;
while (i < content.length && depth > 0) {
const ch = content.charCodeAt(i);
if (ch === 0x7b /* { */) depth++;
else if (ch === 0x7d /* } */) depth--;
i++;
}
const body = content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1));
const pulses = /\bopacity\s*:/i.test(body)
|| /\bbox-shadow\s*:/i.test(body)
|| /\btransform\s*:[^;{}]*\bscale/i.test(body);
if (!map.has(m[1]) || pulses) map.set(m[1], pulses);
re.lastIndex = i;
}
return map;
}
const ANIMATION_VALUE_KEYWORDS = new Set([
'ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear',
'infinite', 'alternate', 'alternate-reverse', 'normal', 'reverse',
'none', 'forwards', 'backwards', 'both', 'running', 'paused',
'step-start', 'step-end', 'inherit', 'initial', 'unset',
]);
// Extract animation names that run with iteration-count: infinite from a
// declaration block (shorthand layers or animation-name + iteration-count).
function infiniteAnimationNames(decls) {
const out = [];
const shorthand = decls.get('animation');
if (shorthand) {
for (const layer of shorthand.split(/,(?![^(]*\))/)) {
if (!/\binfinite\b/i.test(layer)) continue;
const name = layer.split(/\s+/).find(t =>
/^[a-zA-Z_-][\w-]*$/.test(t) && !ANIMATION_VALUE_KEYWORDS.has(t.toLowerCase()));
if (name) out.push(name);
}
}
const nameDecl = decls.get('animation-name');
if (nameDecl && /\binfinite\b/i.test(decls.get('animation-iteration-count') || '')) {
for (const raw of nameDecl.split(',')) {
const t = raw.trim();
if (t && t.toLowerCase() !== 'none') out.push(t);
}
}
return out;
}
function isRoundDotRadius(radiusValue, w, h) {
if (!radiusValue) return false;
const first = String(radiusValue).trim().split(/\s+/)[0];
const pct = first.match(/^([\d.]+)%$/);
if (pct) return parseFloat(pct[1]) >= 40;
const px = cssLengthToPx(first);
if (px == null) return false;
return px >= 999 || px >= 0.4 * Math.min(w, h);
}
// Small circular indicator bound to an infinite pulse animation — the
// decorative "live" dot. Gates: tiny (<= 16px square-ish), round
// (border-radius >= 40% or pill values), and an infinite animation whose
// keyframes vary opacity/scale/box-shadow (or a pulse/blink/ping name when
// the keyframes aren't in the scanned text). Rotation-only animations
// (spinners) never flag.
function scanCssTextForPulsingDot(content) {
const customProps = collectCssCustomProps(content);
const keyframes = collectPulseKeyframes(content);
const findings = [];
const seen = new Set();
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
const decls = parseCssDeclBlock(m[2]);
const names = infiniteAnimationNames(decls);
if (names.length === 0) continue;
const pulseName = names.find(n => {
const known = keyframes.get(n);
if (known != null) return known;
return /pulse|blink|ping/i.test(n);
});
if (!pulseName) continue;
const w = cssLengthToPx(resolveVarRefs(
decls.get('width') || decls.get('inline-size') || '', customProps));
const h = cssLengthToPx(resolveVarRefs(
decls.get('height') || decls.get('block-size') || '', customProps));
if (w == null || h == null || w < 2 || h < 2 || w > 16 || h > 16) continue;
const radius = resolveVarRefs(decls.get('border-radius') || '', customProps);
if (!isRoundDotRadius(radius, w, h)) continue;
if (seen.has(selector)) continue;
seen.add(selector);
findings.push({
id: 'pulsing-dot',
snippet: `${selector}${w}x${h}px dot with infinite "${pulseName}" animation`,
});
}
// Tailwind utilities: animate-ping / animate-pulse on a tiny rounded-full
// element declared entirely in the class attribute.
const classRe = /class\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
let cm;
while ((cm = classRe.exec(content)) !== null) {
const cls = cm[1] || cm[2] || '';
const anim = cls.match(/\banimate-(ping|pulse)\b/);
if (!anim) continue;
if (!/\brounded-full\b/.test(cls)) continue;
if (!/\b(?:w|h|size)-(?:1|1\.5|2|2\.5|3|3\.5|4)\b/.test(cls)) continue;
const key = `tw:${cls}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({
id: 'pulsing-dot',
snippet: `animate-${anim[1]} on tiny rounded-full element`,
});
}
return findings;
}
/**
* Regex-on-HTML checks shared between browser and Node page-level detection.
* These don't need DOM access, just the raw HTML string.
@@ -592,6 +839,13 @@ function checkHtmlPatterns(html) {
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
}
// --- Borders ---
// Side-tab accent stripe drawn as an absolutely-positioned pseudo-element
// (no border property involved, so the element-level border checks and
// the border-left regexes never see it).
findings.push(...scanCssTextForPseudoStripe(html));
// --- Layout ---
// Monotonous spacing
@@ -667,6 +921,9 @@ function checkHtmlPatterns(html) {
}
}
// Pulsing status dots (tiny circular elements on infinite pulse animations)
findings.push(...scanCssTextForPulsingDot(html));
// --- Dark glow / chromatic halo shadows ---
const glowHits = scanCssTextForGlow(html);
@@ -2870,6 +3127,8 @@ export {
checkMotion,
checkGlow,
scanCssTextForGlow,
scanCssTextForPseudoStripe,
scanCssTextForPulsingDot,
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
+2 -2
View File
@@ -521,7 +521,7 @@ import '../styles/testimonials.css';
<article class="ks-bento-tile ks-bento-tile--span-6" id="why-ci">
<span class="ks-bento-num" data-color="patina">06</span>
<h3 class="why-panel-title">Block slop before it ships.</h3>
<p class="why-panel-body">A detector you can wire into PR checks. 46 deterministic rules, no LLM, exit codes the build can read.</p>
<p class="why-panel-body">A detector you can wire into PR checks. 47 deterministic rules, no LLM, exit codes the build can read.</p>
<div class="why-visual why-visual--ci">
<div class="why-ci-window">
<div class="why-ci-header">
@@ -799,7 +799,7 @@ import '../styles/testimonials.css';
</li>
<li>
<strong>CLI for CI</strong>
<span><code>npx impeccable detect src/</code> in a PR check. 46 deterministic rules. JSON output, exit codes for build gates.</span>
<span><code>npx impeccable detect src/</code> in a PR check. 47 deterministic rules. JSON output, exit codes for build gates.</span>
<a href="https://www.npmjs.com/package/impeccable" target="_blank" rel="noopener">View on npm →</a>
</li>
<li>
+162
View File
@@ -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
View File
@@ -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
View File
@@ -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>