From cfdb7d4c81961abca46a7114a0671cbfd4fd0a9d Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sat, 11 Jul 2026 19:06:41 -0700 Subject: [PATCH] detector: catch pseudo-element side stripes; add pulsing-dot rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 6 +- README.npm.md | 4 +- cli/engine/detect-antipatterns-browser.js | 266 ++++++++++++++++++ cli/engine/registry/antipatterns.mjs | 9 + cli/engine/rules/checks.mjs | 259 +++++++++++++++++ site/pages/index.astro | 4 +- tests/detect-antipatterns.test.js | 162 +++++++++++ .../fixtures/antipatterns/pseudo-stripe.html | 99 +++++++ tests/fixtures/antipatterns/pulsing-dot.html | 96 +++++++ 9 files changed, 898 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/antipatterns/pseudo-stripe.html create mode 100644 tests/fixtures/antipatterns/pulsing-dot.html diff --git a/README.md b/README.md index 9585758b6..86984fc71 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.npm.md b/README.npm.md index 730299120..dbf411d66 100644 --- a/README.npm.md +++ b/README.npm.md @@ -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 diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index ddf0ae5a9..d8b072748 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -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); diff --git a/cli/engine/registry/antipatterns.mjs b/cli/engine/registry/antipatterns.mjs index 70aea105d..712bf5024 100644 --- a/cli/engine/registry/antipatterns.mjs +++ b/cli/engine/registry/antipatterns.mjs @@ -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', diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index a010095db..ee82a1f0c 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -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, diff --git a/site/pages/index.astro b/site/pages/index.astro index 26034cdc3..067174b3e 100644 --- a/site/pages/index.astro +++ b/site/pages/index.astro @@ -521,7 +521,7 @@ import '../styles/testimonials.css';
06

Block slop before it ships.

-

A detector you can wire into PR checks. 46 deterministic rules, no LLM, exit codes the build can read.

+

A detector you can wire into PR checks. 47 deterministic rules, no LLM, exit codes the build can read.

@@ -799,7 +799,7 @@ import '../styles/testimonials.css';
  • CLI for CI - npx impeccable detect src/ in a PR check. 46 deterministic rules. JSON output, exit codes for build gates. + npx impeccable detect src/ in a PR check. 47 deterministic rules. JSON output, exit codes for build gates. View on npm →
  • diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index d63f49fd4..4c721d89b 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -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 = ` + +
    + `; + const f = scanCssTextForPulsingDot(html); + expect(f).toHaveLength(1); + expect(f[0].snippet).toContain('animate-ping'); + }); +}); + // --------------------------------------------------------------------------- // ANTIPATTERNS registry diff --git a/tests/fixtures/antipatterns/pseudo-stripe.html b/tests/fixtures/antipatterns/pseudo-stripe.html new file mode 100644 index 000000000..4cc68994d --- /dev/null +++ b/tests/fixtures/antipatterns/pseudo-stripe.html @@ -0,0 +1,99 @@ + + + +Pseudo-element side stripe fixture + + + +
    Card with pseudo stripe
    +
    Row with right stripe
    +
    One
    Two
    +
    Timeline
    +
    Quoted text
    + +
    Panel
    +

    Heading

    + + diff --git a/tests/fixtures/antipatterns/pulsing-dot.html b/tests/fixtures/antipatterns/pulsing-dot.html new file mode 100644 index 000000000..eee81c6f0 --- /dev/null +++ b/tests/fixtures/antipatterns/pulsing-dot.html @@ -0,0 +1,96 @@ + + + +Pulsing status dot fixture + + + + + Online + + + +
    Card
    + + + + + +
    + +