Fix false positives: bg-black opacity modifiers and background-image contrast

Two detector bugs that produced false positives on sites like uselinkshot.com:

1. The bg-black regex matched Tailwind opacity modifiers (bg-black/3,
   hover:bg-black/5) because / is a word boundary. Added negative lookahead.

2. resolveBackground ignored url() background-images, walking past them to
   the body's white bg. White text on a dark hero image was flagged as
   1.0:1 white-on-white. Now bails on url() images like it does for gradients.

Also: extension build auto-generates dist/extension.zip, version bumps for
CLI (2.1.7) and extension (1.0.1).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-10 12:57:06 -07:00
co-authored by Claude Opus 4.6
parent 7d29aaca1b
commit 00d485659a
7 changed files with 83 additions and 27 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Impeccable",
"description": "Detect common UI anti-patterns in any web page",
"version": "1.0.0",
"version": "1.0.1",
"permissions": ["activeTab", "scripting", "storage", "webNavigation"],
"host_permissions": ["<all_urls>"],
"background": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impeccable",
"version": "2.1.6",
"version": "2.1.7",
"author": "Paul Bakaus",
"description": "Design skills, commands, and anti-pattern detection for AI coding agents",
"keywords": [
+11 -15
View File
@@ -5,10 +5,9 @@
*
* 1. Generates the extension variant of the browser detector
* 2. Extracts antipatterns.json for the panel UI
* 3. Optionally packages as a .zip for Chrome Web Store
* 3. Packages as extension.zip for Chrome Web Store upload
*
* Run: node scripts/build-extension.js
* node scripts/build-extension.js --zip
*/
import fs from 'fs';
@@ -73,17 +72,14 @@ if (apMatch) {
// --- 3. Zip packaging ---
if (process.argv.includes('--zip')) {
const archiver = (await import('archiver')).default;
const zipPath = path.join(ROOT, 'dist/impeccable-extension.zip');
fs.mkdirSync(path.dirname(zipPath), { recursive: true });
import { execSync } from 'child_process';
const zipStream = fs.createWriteStream(zipPath);
const archive = archiver('zip', { zlib: { level: 9 } });
archive.pipe(zipStream);
archive.directory(EXT_DIR, false);
await archive.finalize();
const size = fs.statSync(zipPath).size;
console.log(`Packaged ${path.relative(ROOT, zipPath)} (${(size / 1024).toFixed(1)} KB)`);
}
const zipPath = path.join(ROOT, 'dist/extension.zip');
fs.mkdirSync(path.join(ROOT, 'dist'), { recursive: true });
try { fs.unlinkSync(zipPath); } catch {}
execSync(
`zip -r ${JSON.stringify(zipPath)} . -x "STORE_LISTING.md" ".DS_Store"`,
{ cwd: EXT_DIR, stdio: 'pipe' },
);
const size = fs.statSync(zipPath).size;
console.log(`Packaged ${path.relative(ROOT, zipPath)} (${(size / 1024).toFixed(1)} KB)`);
+7 -5
View File
@@ -516,7 +516,7 @@ function checkColors(opts) {
// Tailwind class checks
if (classList) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
if (/\bbg-black\b/.test(classStr)) {
if (/\bbg-black\b(?!\/)/.test(classStr)) {
findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
}
@@ -822,9 +822,11 @@ function resolveBackground(el, win) {
while (current && current.nodeType === 1) {
const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
// If this element has a gradient background, it's opaque but we can't determine the color
// If this element has a background-image (gradient or url), it's visually
// opaque but we can't determine the effective color — bail out so callers
// don't get a false solid-color answer.
const bgImage = style.backgroundImage || '';
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
if (bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage))) {
return null;
}
@@ -834,8 +836,8 @@ function resolveBackground(el, win) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
// Check for gradient in inline style too
if (/gradient/i.test(inlineBg)) return null;
// Check for gradient or url() image in inline style too
if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return null;
bg = parseRgb(inlineBg);
if (!bg && inlineBg) {
const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
+7 -5
View File
@@ -511,7 +511,7 @@ function checkColors(opts) {
// Tailwind class checks
if (classList) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
if (/\bbg-black\b/.test(classStr)) {
if (/\bbg-black\b(?!\/)/.test(classStr)) {
findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
}
@@ -817,9 +817,11 @@ function resolveBackground(el, win) {
while (current && current.nodeType === 1) {
const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
// If this element has a gradient background, it's opaque but we can't determine the color
// If this element has a background-image (gradient or url), it's visually
// opaque but we can't determine the effective color — bail out so callers
// don't get a false solid-color answer.
const bgImage = style.backgroundImage || '';
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
if (bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage))) {
return null;
}
@@ -829,8 +831,8 @@ function resolveBackground(el, win) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
// Check for gradient in inline style too
if (/gradient/i.test(inlineBg)) return null;
// Check for gradient or url() image in inline style too
if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return null;
bg = parseRgb(inlineBg);
if (!bg && inlineBg) {
const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
@@ -59,6 +59,41 @@ describe('detectHtml — jsdom fixtures', () => {
);
});
it('color: white text on background-image url() ancestor is not flagged as low-contrast', async () => {
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
// The pass column has white text on a div with background-image: url().
// The detector can't know the image color, so it must not assume the body
// bg and report a false low-contrast finding (#ffffff on #fafafa).
const falsePositive = f.filter(r =>
r.antipattern === 'low-contrast' &&
/#ffffff on #fafafa/i.test(r.snippet || '')
);
assert.equal(
falsePositive.length, 0,
`expected no low-contrast from bg-image ancestor, got: ${falsePositive.map(r => r.snippet).join('; ')}`
);
});
it('color: Tailwind bg-black/N opacity modifiers are not flagged as pure-black-white', async () => {
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
// The pass column has bg-black/3, hover:bg-black/5, bg-black/50 — none are pure black.
// Only the flag column's literal bg-black class should trigger pure-black-white.
const pureBlackFindings = f.filter(r => r.antipattern === 'pure-black-white');
const opacityFalsePositives = pureBlackFindings.filter(r =>
(r.snippet || '').includes('bg-black') &&
f.some(() => true) // check that bg-black/N class triggers are absent
);
// There should be exactly the flag-column hits (bg-black class + #000000 inline)
// and zero from the pass-column opacity variants.
// The pass-column elements have data-test attributes starting with "bg-black-"
// The Tailwind class check produces snippet "bg-black" — count those.
const twSnippets = pureBlackFindings.filter(r => (r.snippet || '') === 'bg-black');
assert.equal(
twSnippets.length, 1,
`expected exactly 1 Tailwind bg-black finding (flag column only), got ${twSnippets.length}: ${twSnippets.map(r => r.snippet).join('; ')}`
);
});
it('color: emoji-only text is never flagged as low-contrast', async () => {
// Emojis render as multicolor glyphs regardless of CSS `color`, so the
// CSS text color is irrelevant for contrast. The fixture's emoji cards
+21
View File
@@ -97,6 +97,27 @@
<h3 style="color: rgb(220, 38, 38); font-size: 1.25rem; margin: 0;">Red heading — not AI purple</h3>
<h3 style="color: rgb(180, 83, 9); font-size: 1.25rem; margin: 8px 0 0;">Amber heading — distinctive</h3>
<h3>Background-image url() ancestor (not low contrast)</h3>
<!-- White text on transparent bg where ancestor has a background-image.
The detector cannot determine the image color, so it must NOT assume
white and report a false low-contrast finding. -->
<div data-test="bg-image-ancestor" style="background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPj/HwADBwIAMCbHYQAAAABJRU5ErkJggg=='); background-size: cover; padding: 20px;">
<p style="color: rgb(255, 255, 255); font-size: 16px;">White text on image background — should not flag low-contrast</p>
</div>
<h3>Tailwind opacity-modified bg-black (not pure black)</h3>
<!-- bg-black/N classes are NOT pure black — they apply an alpha modifier.
The detector must not match these as pure-black-white. -->
<div class="bg-black/3 p-4 rounded card" data-test="bg-black-opacity" style="background: rgba(0,0,0,0.03);">
<p>bg-black/3 — 3% opacity, not pure black</p>
</div>
<div class="hover:bg-black/5 p-4 rounded card" data-test="bg-black-hover-opacity" style="background: rgba(0,0,0,0);">
<p>hover:bg-black/5 — hover state, not pure black</p>
</div>
<div class="bg-black/50 p-4 rounded card" data-test="bg-black-half-opacity" style="background: rgba(0,0,0,0.5);">
<p style="color: white;">bg-black/50 — 50% opacity, not pure black</p>
</div>
<h3>Emoji on light backgrounds</h3>
<!-- Emojis render as multicolor glyphs regardless of CSS color, so the
CSS color is irrelevant for contrast. These should NOT be flagged. -->